From 16a1990486581a41cfbc73b1cc467d744726a6e9 Mon Sep 17 00:00:00 2001 From: Deukhoofd Date: Sun, 5 Jul 2026 13:01:12 +0200 Subject: [PATCH] More unit tests, fixes --- .../Scripts/Moves/BanefulBunkerTests.cs | 283 +++++++++++++++ .../Scripts/Moves/BatonPassTests.cs | 255 ++++++++++++++ .../Scripts/Moves/BeakBlastTests.cs | 193 +++++++++++ .../Scripts/Moves/BeatUpTests.cs | 229 ++++++++++++ .../Scripts/Moves/BelchTests.cs | 133 +++++++ .../Scripts/Moves/BellyDrumTests.cs | 203 +++++++++++ .../Scripts/Moves/BestowTests.cs | 175 ++++++++++ .../Scripts/Moves/BideTests.cs | 280 +++++++++++++++ .../Scripts/Moves/BindTests.cs | 257 ++++++++++++++ .../Scripts/Moves/BlockTests.cs | 119 +++++++ .../Scripts/Moves/BounceTests.cs | 327 ++++++++++++++++++ .../Scripts/Moves/BrickBreakTests.cs | 180 ++++++++++ .../Scripts/Moves/BrineTests.cs | 101 ++++++ .../Scripts/Moves/BugBiteTests.cs | 243 +++++++++++++ .../Scripts/Moves/BurnUpTests.cs | 199 +++++++++++ .../Scripts/Moves/BatonPass.cs | 2 +- .../Scripts/Moves/BellyDrum.cs | 5 + .../Scripts/Moves/Bestow.cs | 4 +- .../PkmnLib.Plugin.Gen7/Scripts/Moves/Bide.cs | 15 +- .../Scripts/Moves/Block.cs | 4 + .../Scripts/Moves/BrickBreak.cs | 6 +- .../Scripts/Pokemon/BanefulBunkerEffect.cs | 2 +- .../Scripts/Pokemon/ChargeBounceEffect.cs | 2 +- 23 files changed, 3204 insertions(+), 13 deletions(-) create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BanefulBunkerTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BatonPassTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeakBlastTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeatUpTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BelchTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BellyDrumTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BestowTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BideTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BindTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BlockTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BounceTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrickBreakTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrineTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BugBiteTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BurnUpTests.cs diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BanefulBunkerTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BanefulBunkerTests.cs new file mode 100644 index 0000000..ae96e06 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BanefulBunkerTests.cs @@ -0,0 +1,283 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Pokemon; +using PkmnLib.Static.Moves; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script and its attached . +/// Behavior is verified against the Bulbapedia page for Baneful Bunker (Generation VII). +/// +public class BanefulBunkerTests +{ + /// + /// Creates a fully mocked setup for driving 's inherited + /// . The target of the secondary effect is the + /// Pokémon using Baneful Bunker itself, as the move is self-targeted. + /// + private static (BanefulBunker script, IExecutingMove move, IPokemon target, IHitData hitData, IScriptSet volatileSet + ) CreateProtectSetup(bool userMovesLast, float randomRoll) + { + var script = new BanefulBunker(); + var move = Substitute.For(); + var target = Substitute.For(); + var hitData = Substitute.For(); + move.GetHitData(target, 0).Returns(hitData); + + // A queue with a remaining choice means another Pokémon still has to move after the user. + var queue = userMovesLast ? new BattleChoiceQueue([]) : new BattleChoiceQueue([Substitute.For()]); + + var random = Substitute.For(); + random.GetFloat().Returns(randomRoll); + + var battle = Substitute.For(); + battle.ChoiceQueue.Returns(queue); + battle.Random.Returns(random); + + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + target.BattleData.Returns(battleData); + + // Give the mock a real script iterator (used by the volatile add hook) and a real volatile script set. + target.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>())); + IScriptSet volatileSet = new ScriptSet(target); + target.Volatile.Returns(volatileSet); + + return (script, move, target, hitData, volatileSet); + } + + /// + /// Creates a fully mocked setup for driving , the + /// volatile script that attaches to its user. + /// + private static (BanefulBunkerEffect effect, IExecutingMove move, IPokemon target, IPokemon attacker) + CreateBlockSetup(bool isContact, bool hasProtectFlag, MoveCategory category = MoveCategory.Physical) + { + var effect = new BanefulBunkerEffect(); + var move = Substitute.For(); + var target = Substitute.For(); + var hitData = Substitute.For(); + hitData.IsContact.Returns(isContact); + move.GetHitData(target, 0).Returns(hitData); + + var useMove = Substitute.For(); + useMove.HasFlag(new StringKey("protect")).Returns(hasProtectFlag); + useMove.Category.Returns(category); + move.UseMove.Returns(useMove); + + var attacker = Substitute.For(); + attacker.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>())); + move.User.Returns(attacker); + + target.BattleData.Returns(Substitute.For()); + + return (effect, move, target, attacker); + } + + /// + /// Helper to extract the status name from a Pokémon's received calls. + /// + private static string? GetStatusSet(IPokemon pokemon) + { + var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "SetStatus"); + return call != null ? ((StringKey)call.GetArguments()[0]!).ToString() : null; + } + + /// + /// Bulbapedia: "Baneful Bunker protects the user from all effects of moves that target it during the turn + /// it is used, including damage." + /// Using the move attaches the volatile script to the user. + /// + [Test] + public async Task OnSecondaryEffect_FirstUse_AddsBanefulBunkerEffect() + { + // Arrange + var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.0f); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(volatileSet.Get()).IsNotNull(); + hitData.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: "If the user goes last in the turn, the move will fail." + /// + [Test] + public async Task OnSecondaryEffect_UserMovesLast_Fails() + { + // Arrange + var (script, move, target, hitData, volatileSet) = CreateProtectSetup(true, 0.0f); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + await Assert.That(volatileSet.Get()).IsNull(); + } + + /// + /// Bulbapedia: "The chance that Baneful Bunker will succeed also drops each time the user successfully and + /// consecutively uses Endure, any protection move that only affects the user, Quick Guard, or Wide Guard." + /// A successful use registers a consecutive protection turn on the . + /// + [Test] + public async Task OnSecondaryEffect_FirstUse_RegistersConsecutiveProtectionUse() + { + // Arrange + var (script, move, target, _, volatileSet) = CreateProtectSetup(false, 0.0f); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + var failure = volatileSet.Get(); + await Assert.That(failure).IsNotNull(); + await Assert.That(failure!.ProtectTurns).IsEqualTo(1); + await Assert.That(failure.UsedProtect).IsTrue(); + } + + /// + /// Bulbapedia: "Each time, the chance of success is divided by 3." + /// After one consecutive protection use the chance is 1/3, so a roll above 1/3 makes the move fail. + /// + [Test] + public async Task OnSecondaryEffect_SecondConsecutiveUse_RollAboveOneThird_Fails() + { + // Arrange + var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.5f); + volatileSet.Add(new ProtectionFailureScript { ProtectTurns = 1 }); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + await Assert.That(volatileSet.Get()).IsNull(); + } + + /// + /// Bulbapedia: "Each time, the chance of success is divided by 3." + /// After one consecutive protection use the chance is 1/3, so a roll below 1/3 still succeeds and + /// increments the consecutive use counter. + /// + [Test] + public async Task OnSecondaryEffect_SecondConsecutiveUse_RollBelowOneThird_Succeeds() + { + // Arrange + var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.2f); + var failure = new ProtectionFailureScript { ProtectTurns = 1 }; + volatileSet.Add(failure); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.DidNotReceive().Fail(); + await Assert.That(volatileSet.Get()).IsNotNull(); + await Assert.That(failure.ProtectTurns).IsEqualTo(2); + } + + /// + /// Bulbapedia: "Baneful Bunker protects the user from all effects of moves that target it during the turn + /// it is used, including damage." + /// A move that can be protected against (it has the protect flag) is blocked by the + /// . + /// + [Test] + public async Task BlockIncomingHit_MoveWithProtectFlag_BlocksHit() + { + // Arrange + var (effect, move, target, _) = CreateBlockSetup(false, true); + var block = false; + + // Act + effect.BlockIncomingHit(move, target, 0, ref block); + + // Assert + await Assert.That(block).IsTrue(); + } + + /// + /// Bulbapedia: "Baneful Bunker protects the user from all effects of moves that target it"; moves that + /// bypass protection (they lack the protect flag) are not blocked by the . + /// + [Test] + public async Task BlockIncomingHit_MoveWithoutProtectFlag_DoesNotBlock() + { + // Arrange + var (effect, move, target, _) = CreateBlockSetup(false, false); + var block = false; + + // Act + effect.BlockIncomingHit(move, target, 0, ref block); + + // Assert + await Assert.That(block).IsFalse(); + } + + /// + /// Bulbapedia: "If Baneful Bunker blocks an attack that would make contact with the user, the attacker + /// becomes poisoned (unless they are immune)." + /// + [Test] + public async Task BlockIncomingHit_BlockedContactMove_PoisonsAttacker() + { + // Arrange + var (effect, move, target, attacker) = CreateBlockSetup(true, true); + var block = false; + + // Act + effect.BlockIncomingHit(move, target, 0, ref block); + + // Assert + await Assert.That(GetStatusSet(attacker)).IsEqualTo("poisoned"); + } + + /// + /// Bulbapedia: "If Baneful Bunker blocks an attack that would make contact with the user, the attacker + /// becomes poisoned (unless they are immune)." + /// A blocked attack that does not make contact must not poison the attacker. + /// + [Test] + public async Task BlockIncomingHit_BlockedNonContactMove_DoesNotPoisonAttacker() + { + // Arrange + var (effect, move, target, attacker) = CreateBlockSetup(false, true); + var block = false; + + // Act + effect.BlockIncomingHit(move, target, 0, ref block); + + // Assert + await Assert.That(attacker.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse(); + } + + /// + /// Bulbapedia: "If Baneful Bunker blocks an attack that would make contact with the user, the attacker + /// becomes poisoned (unless they are immune)." + /// A contact move that bypasses the protection (it lacks the protect flag, e.g. Shadow Force) is not + /// blocked, so the attacker must not be poisoned. + /// + [Test] + public async Task BlockIncomingHit_ContactMoveThatBypassesProtection_DoesNotPoisonAttacker() + { + // Arrange + var (effect, move, target, attacker) = CreateBlockSetup(true, false); + var block = false; + + // Act + effect.BlockIncomingHit(move, target, 0, ref block); + + // Assert - the hit was not blocked, so no poison is applied + await Assert.That(block).IsFalse(); + await Assert.That(attacker.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BatonPassTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BatonPassTests.cs new file mode 100644 index 0000000..a186828 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BatonPassTests.cs @@ -0,0 +1,255 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Pokemon; +using PkmnLib.Plugin.Gen7.Scripts.Utils; +using PkmnLib.Static; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Behavior is verified against the Bulbapedia page for Baton Pass (Generation VII). +/// In this codebase, volatile scripts that are transferred by Baton Pass are marked with +/// (e.g. ). +/// +public class BatonPassTests +{ + /// + /// Creates a mocked Pokémon with a real volatile and a real + /// . + /// + private static IPokemon CreateMockPokemon(out IScriptSet volatileSet) + { + var pokemon = Substitute.For(); + pokemon.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>())); + var set = new ScriptSet(pokemon); + pokemon.Volatile.Returns(set); + pokemon.StatBoost.Returns(new StatBoostStatisticSet()); + volatileSet = set; + return pokemon; + } + + /// + /// Creates a fully mocked test setup for Baton Pass tests. The Pokémon to switch in is stored in the + /// move choice's under the to_switch key. + /// + private static (BatonPass script, IExecutingMove move, IPokemon user, IPokemon toSwitch, IBattleSide side, + IScriptSet userVolatile, IScriptSet switchInVolatile) CreateTestSetup() + { + var script = new BatonPass(); + var move = Substitute.For(); + var user = CreateMockPokemon(out var userVolatile); + var toSwitch = CreateMockPokemon(out var switchInVolatile); + + var moveChoice = Substitute.For(); + moveChoice.AdditionalData.Returns(new Dictionary { { "to_switch", toSwitch } }); + move.MoveChoice.Returns(moveChoice); + + var side = Substitute.For(); + var battle = Substitute.For(); + battle.Sides.Returns(new[] { side }); + + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + battleData.SideIndex.Returns((byte)0); + battleData.Position.Returns((byte)1); + user.BattleData.Returns(battleData); + + move.User.Returns(user); + return (script, move, user, toSwitch, side, userVolatile, switchInVolatile); + } + + /// + /// Bulbapedia: "Baton Pass switches out the user and passes several effects to the Pokémon switched in + /// its place." + /// The user is swapped out for the chosen Pokémon on its own field position. + /// + [Test] + public void OnSecondaryEffect_SwitchTargetChosen_SwitchesOutUser() + { + // Arrange + var (script, move, user, toSwitch, side, _, _) = CreateTestSetup(); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + side.Received(1).SwapPokemon((byte)1, toSwitch); + } + + /// + /// Bulbapedia: "Baton Pass transfers all temporary stat stage increases and decreases". + /// Each stat stage (including evasion and accuracy) is copied to the Pokémon that is switched in. + /// + [Test, Arguments(Statistic.Attack, (sbyte)6), Arguments(Statistic.Defense, (sbyte)-6), + Arguments(Statistic.SpecialAttack, (sbyte)2), Arguments(Statistic.SpecialDefense, (sbyte)-1), + Arguments(Statistic.Speed, (sbyte)3), Arguments(Statistic.Evasion, (sbyte)1), + Arguments(Statistic.Accuracy, (sbyte)-2)] + public async Task OnSecondaryEffect_StatBoost_TransferredToSwitchIn(Statistic stat, sbyte boost) + { + // Arrange + var (script, move, user, toSwitch, _, _, _) = CreateTestSetup(); + user.StatBoost.SetStatistic(stat, boost); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + await Assert.That(toSwitch.StatBoost.GetStatistic(stat)).IsEqualTo(boost); + } + + /// + /// Bulbapedia: "Baton Pass transfers all temporary stat stage increases and decreases". + /// After the stat stages are copied, the switched-in Pokémon's boosted stats are recalculated. + /// + [Test] + public void OnSecondaryEffect_StatBoostsTransferred_RecalculatesSwitchInStats() + { + // Arrange + var (script, move, user, toSwitch, _, _, _) = CreateTestSetup(); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + toSwitch.Received(1).RecalculateBoostedStats(); + } + + /// + /// Bulbapedia: "Baton Pass switches out the user and passes several effects to the Pokémon switched in + /// its place." + /// A volatile script that is marked as transferable (, such as + /// ) is passed to the switched-in Pokémon. + /// + [Test] + public async Task OnSecondaryEffect_TransferableVolatile_PassedToSwitchIn() + { + // Arrange + var (script, move, user, _, _, userVolatile, switchInVolatile) = CreateTestSetup(); + userVolatile.Add(new AutotomizeEffect()); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + await Assert.That(switchInVolatile.Get()).IsNotNull(); + } + + /// + /// Bulbapedia: "Baton Pass also passes some volatile status conditions, namely confusion, getting pumped, + /// escape prevention, seeding, Curse, Substitute, Ingrain, Aqua Ring" — effects outside that list are not + /// passed. The consecutive-protection counter () is not transferable, + /// so it must not end up on the switched-in Pokémon. + /// + [Test] + public async Task OnSecondaryEffect_NonTransferableVolatile_NotPassedToSwitchIn() + { + // Arrange + var (script, move, user, _, _, userVolatile, switchInVolatile) = CreateTestSetup(); + userVolatile.Add(new ProtectionFailureScript()); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + await Assert.That(switchInVolatile.Get()).IsNull(); + } + + /// + /// Bulbapedia: "Baton Pass switches out the user and passes several effects to the Pokémon switched in + /// its place." + /// A transferable volatile must be passed even when the user also has a non-transferable volatile that + /// was added before it. + /// + [Test] + public async Task OnSecondaryEffect_TransferableVolatileAfterNonTransferable_StillPassedToSwitchIn() + { + // Arrange + var (script, move, user, _, _, userVolatile, switchInVolatile) = CreateTestSetup(); + userVolatile.Add(new ProtectionFailureScript()); + userVolatile.Add(new AutotomizeEffect()); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + await Assert.That(switchInVolatile.Get()).IsNotNull(); + await Assert.That(switchInVolatile.Get()).IsNull(); + } + + /// + /// Bulbapedia: "Baton Pass switches out the user". + /// The user's volatile scripts leave the field with it: after the switch its volatile containers are empty. + /// + [Test] + public async Task OnSecondaryEffect_UserSwitchedOut_UserVolatilesCleared() + { + // Arrange + var (script, move, user, _, _, userVolatile, _) = CreateTestSetup(); + userVolatile.Add(new AutotomizeEffect()); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + await Assert.That(userVolatile.Get()).IsNull(); + } + + /// + /// Technical test: without any there is no Pokémon to switch to, + /// so no switch happens. + /// + [Test] + public void OnSecondaryEffect_NoAdditionalData_DoesNotSwitch() + { + // Arrange + var (script, move, user, _, side, _, _) = CreateTestSetup(); + move.MoveChoice.AdditionalData.Returns((Dictionary?)null); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + side.DidNotReceive().SwapPokemon(Arg.Any(), Arg.Any()); + } + + /// + /// Technical test: if the stored switch target is not a Pokémon, no switch happens. + /// + [Test] + public void OnSecondaryEffect_SwitchTargetNotAPokemon_DoesNotSwitch() + { + // Arrange + var (script, move, user, _, side, _, _) = CreateTestSetup(); + move.MoveChoice.AdditionalData.Returns(new Dictionary { { "to_switch", null } }); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + side.DidNotReceive().SwapPokemon(Arg.Any(), Arg.Any()); + } + + /// + /// Technical test: if the user has no , the script returns without + /// switching and without clearing the user's volatile scripts. + /// + [Test] + public async Task OnSecondaryEffect_NoBattleData_DoesNotSwitch() + { + // Arrange + var (script, move, user, _, side, userVolatile, _) = CreateTestSetup(); + user.BattleData.Returns((IPokemonBattleData?)null); + userVolatile.Add(new AutotomizeEffect()); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + side.DidNotReceive().SwapPokemon(Arg.Any(), Arg.Any()); + await Assert.That(userVolatile.Get()).IsNotNull(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeakBlastTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeakBlastTests.cs new file mode 100644 index 0000000..df2b54d --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeakBlastTests.cs @@ -0,0 +1,193 @@ +using PkmnLib.Dynamic.Events; +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Pokemon; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script and its attached . +/// Behavior is verified against the Bulbapedia page for Beak Blast (Generation VII). +/// +public class BeakBlastTests +{ + /// + /// Creates a fully mocked setup for driving . + /// + private static (BeakBlast script, ITurnChoice choice, IPokemon user, IScriptSet volatileSet, EventHook eventHook) + CreateChargeSetup(bool hasBattleData = true) + { + var script = new BeakBlast(); + var user = Substitute.For(); + user.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>())); + IScriptSet volatileSet = new ScriptSet(user); + user.Volatile.Returns(volatileSet); + + var eventHook = new EventHook(); + if (hasBattleData) + { + var battle = Substitute.For(); + battle.EventHook.Returns(eventHook); + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + user.BattleData.Returns(battleData); + } + else + { + user.BattleData.Returns((IPokemonBattleData?)null); + } + + var choice = Substitute.For(); + choice.User.Returns(user); + + return (script, choice, user, volatileSet, eventHook); + } + + /// + /// Bulbapedia: "The user charges up at the beginning of the turn and executes Beak Blast at -3 priority." + /// Before the turn starts, the charging is attached to the user. + /// + [Test] + public async Task OnBeforeTurnStart_UserInBattle_AddsChargeEffect() + { + // Arrange + var (script, choice, _, volatileSet, _) = CreateChargeSetup(); + + // Act + script.OnBeforeTurnStart(choice); + + // Assert + await Assert.That(volatileSet.Get()).IsNotNull(); + } + + /// + /// Bulbapedia: "Its charging message is displayed before any other moves." + /// The charge fires a dialog event when the turn starts. + /// + [Test] + public async Task OnBeforeTurnStart_UserInBattle_FiresChargingMessage() + { + // Arrange + var (script, choice, user, _, eventHook) = CreateChargeSetup(); + DialogEvent? capturedEvent = null; + eventHook.Handler += (_, args) => + { + if (args is DialogEvent dialogEvent) + capturedEvent = dialogEvent; + }; + + // Act + script.OnBeforeTurnStart(choice); + + // Assert + await Assert.That(capturedEvent).IsNotNull(); + await Assert.That(capturedEvent!.Message).IsEqualTo("beak_blast_charge"); + await Assert.That(capturedEvent.Parameters!["user"]).IsEqualTo(user); + } + + /// + /// Technical test: a Pokémon without is not in battle, so no charging + /// phase starts. + /// + [Test] + public async Task OnBeforeTurnStart_NoBattleData_DoesNotAddChargeEffect() + { + // Arrange + var (script, choice, _, volatileSet, _) = CreateChargeSetup(false); + + // Act + script.OnBeforeTurnStart(choice); + + // Assert + await Assert.That(volatileSet.Get()).IsNull(); + } + + /// + /// Bulbapedia: "All who make contact with the user during the charging phase are burned." + /// The charging phase ends when the move executes, so the is removed again. + /// + [Test] + public async Task OnSecondaryEffect_MoveExecuted_RemovesChargeEffect() + { + // Arrange + var script = new BeakBlast(); + var move = Substitute.For(); + var target = Substitute.For(); + var user = Substitute.For(); + user.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>())); + IScriptSet volatileSet = new ScriptSet(user); + user.Volatile.Returns(volatileSet); + move.User.Returns(user); + volatileSet.Add(new BeakBlastEffect()); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(volatileSet.Get()).IsNull(); + } + + /// + /// Creates a fully mocked setup for driving . + /// + private static (BeakBlastEffect effect, IExecutingMove move, IPokemon target, IPokemon attacker) + CreateIncomingHitSetup(bool isContact) + { + var effect = new BeakBlastEffect(); + var move = Substitute.For(); + var target = Substitute.For(); + var hitData = Substitute.For(); + hitData.IsContact.Returns(isContact); + move.GetHitData(target, 0).Returns(hitData); + + var attacker = Substitute.For(); + move.User.Returns(attacker); + + return (effect, move, target, attacker); + } + + /// + /// Helper to extract the status name from a Pokémon's received calls. + /// + private static string? GetStatusSet(IPokemon pokemon) + { + var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "SetStatus"); + return call != null ? ((StringKey)call.GetArguments()[0]!).ToString() : null; + } + + /// + /// Bulbapedia: "All who make contact with the user during the charging phase are burned." + /// + [Test] + public async Task OnIncomingHit_ContactMove_BurnsAttacker() + { + // Arrange + var (effect, move, target, attacker) = CreateIncomingHitSetup(true); + + // Act + effect.OnIncomingHit(move, target, 0); + + // Assert + await Assert.That(GetStatusSet(attacker)).IsEqualTo("burned"); + } + + /// + /// Bulbapedia: "All who make contact with the user during the charging phase are burned." + /// A hit that does not make contact must not burn the attacker. + /// + [Test] + public async Task OnIncomingHit_NonContactMove_DoesNotBurnAttacker() + { + // Arrange + var (effect, move, target, attacker) = CreateIncomingHitSetup(false); + + // Act + effect.OnIncomingHit(move, target, 0); + + // Assert + await Assert.That(attacker.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeatUpTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeatUpTests.cs new file mode 100644 index 0000000..dfe28be --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeatUpTests.cs @@ -0,0 +1,229 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Status; +using PkmnLib.Static; +using PkmnLib.Static.Species; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Beat Up inflicts damage on the target from the user and from each conscious +/// Pokémon in the user's party that does not have a non-volatile status." Since Generation V, "the base power +/// per strike is no longer 10, but instead individually based on the Attack base stats of the party Pokémon: +/// BasePower = BaseAttack(PartyMember)/10 + 5". +/// +public class BeatUpTests +{ + /// + /// Creates a mocked party member with the given base Attack stat, usability, and optional non-volatile + /// status script in its . + /// + private static IPokemon CreatePartyMember(ushort baseAttack = 100, bool usable = true, Script? status = null) + { + var pokemon = Substitute.For(); + pokemon.IsUsable.Returns(usable); + pokemon.StatusScript.Returns(status == null ? new ScriptContainer() : new ScriptContainer(status)); + var form = Substitute.For(); + form.BaseStats.Returns(new ImmutableStatisticSet(100, baseAttack, 100, 100, 100, 100)); + pokemon.Form.Returns(form); + return pokemon; + } + + /// + /// Creates a fully mocked test setup where the user and the given other Pokémon form a party in a battle. + /// + private static (BeatUp script, IMoveChoice choice, IExecutingMove move) CreateTestSetup(IPokemon user, + params IPokemon?[] otherPartyMembers) + { + var script = new BeatUp(); + + var members = new List { user }; + members.AddRange(otherPartyMembers); + + var party = Substitute.For(); + party.GetEnumerator().Returns(_ => members.GetEnumerator()); + var battleParty = Substitute.For(); + battleParty.Party.Returns(party); + + var battle = Substitute.For(); + battle.Parties.Returns(new[] { battleParty }); + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + user.BattleData.Returns(battleData); + + var choice = Substitute.For(); + choice.User.Returns(user); + var move = Substitute.For(); + move.User.Returns(user); + + return (script, choice, move); + } + + /// + /// Bulbapedia: "Beat Up inflicts damage on the target from the user and from each conscious Pokémon in the + /// user's party that does not have a non-volatile status." + /// With the user and two healthy allies, the move strikes three times. + /// + [Test] + public async Task ChangeNumberOfHits_AllPartyMembersEligible_OneHitPerMember() + { + // Arrange + var user = CreatePartyMember(); + var (script, choice, _) = CreateTestSetup(user, CreatePartyMember(), CreatePartyMember()); + byte numberOfHits = 1; + + // Act + script.ChangeNumberOfHits(choice, ref numberOfHits); + + // Assert + await Assert.That(numberOfHits).IsEqualTo((byte)3); + } + + /// + /// Bulbapedia: "Beat Up inflicts damage on the target from the user and from each conscious Pokémon in the + /// user's party". + /// A fainted (not conscious) party member does not contribute a strike. + /// + [Test] + public async Task ChangeNumberOfHits_FaintedPartyMember_Excluded() + { + // Arrange + var user = CreatePartyMember(); + var (script, choice, _) = CreateTestSetup(user, CreatePartyMember(usable: false), CreatePartyMember()); + byte numberOfHits = 1; + + // Act + script.ChangeNumberOfHits(choice, ref numberOfHits); + + // Assert + await Assert.That(numberOfHits).IsEqualTo((byte)2); + } + + /// + /// Bulbapedia: "each conscious Pokémon in the user's party that does not have a non-volatile status". + /// A party member with a non-volatile status (here ) does not contribute a strike. + /// + [Test] + public async Task ChangeNumberOfHits_StatusedPartyMember_Excluded() + { + // Arrange + var user = CreatePartyMember(); + var (script, choice, _) = CreateTestSetup(user, CreatePartyMember(status: new Burned())); + byte numberOfHits = 1; + + // Act + script.ChangeNumberOfHits(choice, ref numberOfHits); + + // Assert + await Assert.That(numberOfHits).IsEqualTo((byte)1); + } + + /// + /// Technical test: outside of battle (no ) there are no relevant party + /// members, and the number of hits falls back to a single strike. + /// + [Test] + public async Task ChangeNumberOfHits_NoBattleData_SingleHit() + { + // Arrange + var script = new BeatUp(); + var user = Substitute.For(); + user.BattleData.Returns((IPokemonBattleData?)null); + var choice = Substitute.For(); + choice.User.Returns(user); + byte numberOfHits = 3; + + // Act + script.ChangeNumberOfHits(choice, ref numberOfHits); + + // Assert + await Assert.That(numberOfHits).IsEqualTo((byte)1); + } + + /// + /// Bulbapedia: "the base power per strike is no longer 10, but instead individually based on the Attack + /// base stats of the party Pokémon: BasePower = BaseAttack(PartyMember)/10 + 5". + /// Verifies the formula, including integer truncation of the division. + /// + [Test, Arguments((ushort)10, (ushort)6), Arguments((ushort)55, (ushort)10), Arguments((ushort)59, (ushort)10), + Arguments((ushort)100, (ushort)15), Arguments((ushort)255, (ushort)30)] + public async Task ChangeBasePower_UsesBaseAttackFormula(ushort baseAttack, ushort expectedBasePower) + { + // Arrange + var user = CreatePartyMember(baseAttack); + var (script, _, move) = CreateTestSetup(user); + var target = Substitute.For(); + ushort basePower = 10; + + // Act + script.ChangeBasePower(move, target, 0, ref basePower); + + // Assert + await Assert.That(basePower).IsEqualTo(expectedBasePower); + } + + /// + /// Bulbapedia: "the base power per strike is [...] individually based on the Attack base stats of the party + /// Pokémon". The second strike uses the second eligible party member's base Attack, not the user's. + /// + [Test] + public async Task ChangeBasePower_SecondHit_UsesSecondPartyMembersBaseAttack() + { + // Arrange + var user = CreatePartyMember(100); + var (script, _, move) = CreateTestSetup(user, CreatePartyMember(250)); + var target = Substitute.For(); + ushort basePower = 10; + + // Act + script.ChangeBasePower(move, target, 1, ref basePower); + + // Assert - 250 / 10 + 5 = 30 + await Assert.That(basePower).IsEqualTo((ushort)30); + } + + /// + /// Bulbapedia: strikes only come from party members "that [do] not have a non-volatile status". + /// An ineligible member is skipped entirely, so the strike after the user's uses the next eligible + /// member's base Attack. + /// + [Test] + public async Task ChangeBasePower_StatusedMemberSkipped_UsesNextEligibleMember() + { + // Arrange + var user = CreatePartyMember(100); + var (script, _, move) = CreateTestSetup(user, CreatePartyMember(250, status: new Burned()), + CreatePartyMember(60)); + var target = Substitute.For(); + ushort basePower = 10; + + // Act + script.ChangeBasePower(move, target, 1, ref basePower); + + // Assert - the burned member is skipped: 60 / 10 + 5 = 11 + await Assert.That(basePower).IsEqualTo((ushort)11); + } + + /// + /// Technical test: a hit index beyond the number of eligible party members leaves the base power unchanged + /// instead of throwing. + /// + [Test] + public async Task ChangeBasePower_HitIndexBeyondEligibleMembers_BasePowerUnchanged() + { + // Arrange + var user = CreatePartyMember(100); + var (script, _, move) = CreateTestSetup(user); + var target = Substitute.For(); + ushort basePower = 10; + + // Act + script.ChangeBasePower(move, target, 3, ref basePower); + + // Assert + await Assert.That(basePower).IsEqualTo((ushort)10); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BelchTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BelchTests.cs new file mode 100644 index 0000000..564ab0f --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BelchTests.cs @@ -0,0 +1,133 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Belch deals damage. It cannot be selected unless the user has previously eaten +/// a Berry in the current battle." +/// +public class BelchTests +{ + /// + /// Creates a fully mocked test setup where the user's + /// contains one item per given . + /// + private static (Belch script, IMoveChoice choice) CreateTestSetup(params ItemCategory[] consumedItemCategories) + { + var script = new Belch(); + var user = Substitute.For(); + + var items = consumedItemCategories.Select(category => + { + var item = Substitute.For(); + item.Category.Returns(category); + return item; + }).ToArray(); + var battleData = Substitute.For(); + battleData.ConsumedItems.Returns(items); + user.BattleData.Returns(battleData); + + var choice = Substitute.For(); + choice.User.Returns(user); + + return (script, choice); + } + + /// + /// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle." + /// A user that has eaten a Berry may select Belch. + /// + [Test] + public async Task PreventMoveSelection_UserAteBerry_SelectionAllowed() + { + // Arrange + var (script, choice) = CreateTestSetup(ItemCategory.Berry); + var prevent = false; + + // Act + script.PreventMoveSelection(choice, ref prevent); + + // Assert + await Assert.That(prevent).IsFalse(); + } + + /// + /// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle." + /// A user that has not consumed anything cannot select Belch. + /// + [Test] + public async Task PreventMoveSelection_NoItemsConsumed_SelectionPrevented() + { + // Arrange + var (script, choice) = CreateTestSetup(); + var prevent = false; + + // Act + script.PreventMoveSelection(choice, ref prevent); + + // Assert + await Assert.That(prevent).IsTrue(); + } + + /// + /// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle." + /// Having consumed only non-Berry items does not satisfy the requirement. + /// + [Test, Arguments(ItemCategory.MiscItem), Arguments(ItemCategory.Medicine)] + public async Task PreventMoveSelection_OnlyNonBerryItemConsumed_SelectionPrevented(ItemCategory category) + { + // Arrange + var (script, choice) = CreateTestSetup(category); + var prevent = false; + + // Act + script.PreventMoveSelection(choice, ref prevent); + + // Assert + await Assert.That(prevent).IsTrue(); + } + + /// + /// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle." + /// The Berry does not have to be the only consumed item: a Berry among other consumed items is enough. + /// + [Test] + public async Task PreventMoveSelection_BerryAmongOtherConsumedItems_SelectionAllowed() + { + // Arrange + var (script, choice) = CreateTestSetup(ItemCategory.MiscItem, ItemCategory.Berry); + var prevent = false; + + // Act + script.PreventMoveSelection(choice, ref prevent); + + // Assert + await Assert.That(prevent).IsFalse(); + } + + /// + /// Technical test: outside of battle (no ) the script does not prevent + /// selection. + /// + [Test] + public async Task PreventMoveSelection_NoBattleData_SelectionAllowed() + { + // Arrange + var script = new Belch(); + var user = Substitute.For(); + user.BattleData.Returns((IPokemonBattleData?)null); + var choice = Substitute.For(); + choice.User.Returns(user); + var prevent = false; + + // Act + script.PreventMoveSelection(choice, ref prevent); + + // Assert + await Assert.That(prevent).IsFalse(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BellyDrumTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BellyDrumTests.cs new file mode 100644 index 0000000..e5b66fa --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BellyDrumTests.cs @@ -0,0 +1,203 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Belly Drum deducts half of the user's maximum HP (rounded down) from its +/// current HP and, in return, it maximizes the user's Attack stat by raising it to +6 stages". +/// +public class BellyDrumTests +{ + /// + /// Creates a fully mocked test setup for Belly Drum. The target of the secondary effect is the user itself. + /// + private static (BellyDrum script, IExecutingMove move, IPokemon user, IHitData hitData) CreateTestSetup(uint maxHp, + uint currentHp, sbyte attackBoost = 0) + { + var script = new BellyDrum(); + var user = Substitute.For(); + user.BoostedStats.Returns(new StatisticSet(maxHp, 10, 10, 10, 10, 10)); + user.CurrentHealth.Returns(currentHp); + user.StatBoost.Returns(new StatBoostStatisticSet(0, attackBoost, 0, 0, 0, 0)); + + var hitData = Substitute.For(); + var move = Substitute.For(); + move.GetHitData(user, 0).Returns(hitData); + move.User.Returns(user); + + return (script, move, user, hitData); + } + + /// + /// Helper to extract the received Damage call from the user, if any. + /// + private static (uint damage, DamageSource source, bool forceDamage)? GetDamageCall(IPokemon user) + { + var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); + if (call == null) + return null; + var args = call.GetArguments(); + return ((uint)args[0]!, (DamageSource)args[1]!, (bool)args[3]!); + } + + /// + /// Helper to extract the received ChangeStatBoost call from the user, if any. + /// + private static (Statistic stat, sbyte change, bool selfInflicted)? GetStatBoostCall(IPokemon user) + { + var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost"); + if (call == null) + return null; + var args = call.GetArguments(); + return ((Statistic)args[0]!, (sbyte)args[1]!, (bool)args[2]!); + } + + /// + /// Bulbapedia: "Belly Drum deducts half of the user's maximum HP (rounded down) from its current HP". + /// Verifies the deduction amount, including the rounding down for odd maximum HP values. The deduction is + /// a cost rather than regular damage, so it is forced damage. + /// + [Test, Arguments(100u, 50u), Arguments(101u, 50u), Arguments(99u, 49u), Arguments(255u, 127u)] + public async Task OnSecondaryEffect_SufficientHp_DeductsHalfMaxHpRoundedDown(uint maxHp, uint expectedDeduction) + { + // Arrange + var (script, move, user, _) = CreateTestSetup(maxHp, maxHp); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + await Assert.That(GetDamageCall(user)).IsEqualTo((expectedDeduction, DamageSource.Misc, true)); + } + + /// + /// Bulbapedia: "it maximizes the user's Attack stat by raising it to +6 stages". + /// The user receives a self-inflicted Attack boost large enough to reach the +6 cap. + /// + [Test] + public async Task OnSecondaryEffect_SufficientHp_MaximizesAttack() + { + // Arrange + var (script, move, user, _) = CreateTestSetup(100, 100); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + var boostCall = GetStatBoostCall(user); + await Assert.That(boostCall).IsNotNull(); + await Assert.That(boostCall!.Value.stat).IsEqualTo(Statistic.Attack); + await Assert.That(boostCall.Value.change).IsGreaterThanOrEqualTo((sbyte)6); + await Assert.That(boostCall.Value.selfInflicted).IsTrue(); + } + + /// + /// Bulbapedia: "it maximizes the user's Attack stat by raising it to +6 stages, even if the user's + /// temporary Attack bonus stages were below 0 prior to using Belly Drum." + /// From -6 stages only a raise of at least 12 stages reaches +6. + /// + [Test] + public async Task OnSecondaryEffect_AttackStagesBelowZero_StillRaisedToPlusSix() + { + // Arrange + var (script, move, user, _) = CreateTestSetup(100, 100, -6); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + var boostCall = GetStatBoostCall(user); + await Assert.That(boostCall).IsNotNull(); + await Assert.That(boostCall!.Value.change).IsGreaterThanOrEqualTo((sbyte)12); + } + + /// + /// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum". + /// + [Test] + public void OnSecondaryEffect_CurrentHpBelowHalf_Fails() + { + // Arrange + var (script, move, user, hitData) = CreateTestSetup(100, 49); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + hitData.Received(1).Fail(); + } + + /// + /// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum". + /// When the move fails, no HP is deducted and the Attack stat is not changed. + /// + [Test] + public async Task OnSecondaryEffect_CurrentHpBelowHalf_NoHpDeductedAndNoAttackChange() + { + // Arrange + var (script, move, user, _) = CreateTestSetup(100, 49); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + await Assert.That(GetDamageCall(user)).IsNull(); + await Assert.That(GetStatBoostCall(user)).IsNull(); + } + + /// + /// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum". + /// At exactly half its maximum HP the user cannot pay the cost without fainting (deducting half of the + /// maximum HP would reduce it to 0 HP), so the move fails as well. + /// + [Test] + public void OnSecondaryEffect_CurrentHpExactlyHalf_Fails() + { + // Arrange + var (script, move, user, hitData) = CreateTestSetup(100, 50); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + hitData.Received(1).Fail(); + } + + /// + /// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum, or if the user's + /// Attack is already at +6 (even if the user has Contrary)." + /// + [Test] + public void OnSecondaryEffect_AttackAlreadyAtPlusSix_Fails() + { + // Arrange + var (script, move, user, hitData) = CreateTestSetup(100, 100, 6); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + hitData.Received(1).Fail(); + } + + /// + /// Bulbapedia: "Belly Drum fails if [...] the user's Attack is already at +6". + /// A failed Belly Drum does not deduct HP: the HP cost only applies when the move works (the only + /// exception Bulbapedia lists is a Contrary user already at -6 stages). + /// + [Test] + public async Task OnSecondaryEffect_AttackAlreadyAtPlusSix_NoHpDeducted() + { + // Arrange + var (script, move, user, _) = CreateTestSetup(100, 100, 6); + + // Act + script.OnSecondaryEffect(move, user, 0); + + // Assert + await Assert.That(GetDamageCall(user)).IsNull(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BestowTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BestowTests.cs new file mode 100644 index 0000000..1c8b3bb --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BestowTests.cs @@ -0,0 +1,175 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "The user transfers its held item to the target. It fails if the user has no +/// held item, the target already has a held item, or the target is behind a substitute. [...] Items given away +/// in Trainer battles return to the original Pokémon after the battle." +/// +public class BestowTests +{ + private static (Bestow script, IExecutingMove move, IPokemon user, IPokemon target, IHitData hitData) + CreateTestSetup(IItem? userItem, IItem? targetItem) + { + var script = new Bestow(); + var move = Substitute.For(); + var target = Substitute.For(); + var hitData = Substitute.For(); + move.GetHitData(target, 0).Returns(hitData); + target.HeldItem.Returns(targetItem); + + var user = Substitute.For(); + user.HeldItem.Returns(userItem); + user.RemoveHeldItemForBattle().Returns(userItem); + move.User.Returns(user); + + return (script, move, user, target, hitData); + } + + /// + /// Bulbapedia: "The user transfers its held item to the target." + /// The target ends up holding the user's item. + /// + [Test] + public void OnSecondaryEffect_TargetHasNoItem_TargetReceivesUsersItem() + { + // Arrange + var userItem = Substitute.For(); + var (script, move, _, target, _) = CreateTestSetup(userItem, null); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + target.Received(1).ForceSetHeldItem(userItem); + } + + /// + /// Bulbapedia: "The user transfers its held item to the target." + /// A successful transfer does not fail the move. + /// + [Test] + public void OnSecondaryEffect_TargetHasNoItem_MoveDoesNotFail() + { + // Arrange + var userItem = Substitute.For(); + var (script, move, _, target, hitData) = CreateTestSetup(userItem, null); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: "Items given away in Trainer battles return to the original Pokémon after the battle." + /// The item is taken from the user through , which only + /// removes the item for the duration of the battle. + /// + [Test] + public void OnSecondaryEffect_TargetHasNoItem_UserItemIsRemovedForBattleOnly() + { + // Arrange + var userItem = Substitute.For(); + var (script, move, user, target, _) = CreateTestSetup(userItem, null); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + user.Received(1).RemoveHeldItemForBattle(); + } + + /// + /// Bulbapedia: "It fails if the user has no held item". + /// + [Test] + public void OnSecondaryEffect_UserHasNoHeldItem_MoveFails() + { + // Arrange + var (script, move, _, target, hitData) = CreateTestSetup(null, null); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + } + + /// + /// Bulbapedia: "It fails if the user has no held item". + /// When the move fails, no item is given to the target. + /// + [Test] + public void OnSecondaryEffect_UserHasNoHeldItem_TargetDoesNotReceiveAnItem() + { + // Arrange + var (script, move, _, target, _) = CreateTestSetup(null, null); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + target.DidNotReceive().ForceSetHeldItem(Arg.Any()); + } + + /// + /// Bulbapedia: "It fails if [...] the target already has a held item". + /// + [Test] + public void OnSecondaryEffect_TargetAlreadyHasHeldItem_MoveFails() + { + // Arrange + var (script, move, _, target, hitData) = CreateTestSetup(Substitute.For(), Substitute.For()); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + } + + /// + /// Bulbapedia: "It fails if [...] the target already has a held item". + /// The target's own held item is left untouched by the failed move. + /// + [Test] + public void OnSecondaryEffect_TargetAlreadyHasHeldItem_TargetItemIsNotReplaced() + { + // Arrange + var (script, move, _, target, _) = CreateTestSetup(Substitute.For(), Substitute.For()); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + target.DidNotReceive().ForceSetHeldItem(Arg.Any()); + } + + /// + /// Bulbapedia: "It fails if [...] the target already has a held item". + /// A failed Bestow must not cost the user its held item: the item is either never taken from the user, or + /// it is given back after the failure check. + /// + [Test] + public async Task OnSecondaryEffect_TargetAlreadyHasHeldItem_UserKeepsItsHeldItem() + { + // Arrange + var userItem = Substitute.For(); + var (script, move, user, target, _) = CreateTestSetup(userItem, Substitute.For()); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert - the user still holds its item: it was either never removed, or restored after the failure. + var removed = user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "RemoveHeldItemForBattle"); + var restored = user.ReceivedCalls().Any(c => + c.GetMethodInfo().Name == "ForceSetHeldItem" && ReferenceEquals(c.GetArguments()[0], userItem)); + await Assert.That(!removed || restored).IsTrue(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BideTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BideTests.cs new file mode 100644 index 0000000..9170a2a --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BideTests.cs @@ -0,0 +1,280 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Pokemon; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script and its volatile. +/// Gen VII Bulbapedia behavior (base plus Gen III-VII deltas): the user idles while storing the damage it +/// receives, then "Bide will do damage equal to twice the damage received during the idling period", enduring +/// "attacks for two turns" and hitting "the last Pokémon to attack the user, even if this is an ally". +/// +public class BideTests +{ + private static (Bide script, IExecutingMove move, IPokemon user, IPokemon target, IScriptSet userVolatile, IHitData + hitData) CreateTestSetup() + { + var script = new Bide(); + var move = Substitute.For(); + var target = Substitute.For(); + var hitData = Substitute.For(); + move.GetHitData(target, 0).Returns(hitData); + + var user = Substitute.For(); + user.GetScripts().Returns(_ => new ScriptIterator(new List>())); + // A real script set so the volatile Bide effect can actually be added, retrieved and removed. + var userVolatile = new ScriptSet(user); + user.Volatile.Returns(userVolatile); + move.User.Returns(user); + + return (script, move, user, target, userVolatile, hitData); + } + + private static IPokemon CreateAttacker(bool onBattlefield = true) + { + var attacker = Substitute.For(); + var battleData = Substitute.For(); + battleData.IsOnBattlefield.Returns(onBattlefield); + attacker.BattleData.Returns(battleData); + return attacker; + } + + /// + /// Adds a to the user's volatile scripts, as if Bide has already been storing + /// energy for the given number of executed turns. + /// + private static BideEffect AddStoredBideEffect(IScriptSet userVolatile, IPokemon user, byte turns, uint damageTaken, + params IPokemon[] hitBy) + { + var effect = new BideEffect(user) + { + Turns = turns, + DamageTaken = damageTaken, + }; + effect.HitBy.AddRange(hitBy); + userVolatile.Add(effect); + return effect; + } + + /// + /// Helper to check whether a Pokémon received any Damage call. + /// + private static bool ReceivedDamage(IPokemon pokemon) => + pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage"); + + /// + /// Helper to extract the damage amount from a Pokémon's received Damage calls. + /// + private static uint? GetDamageAmount(IPokemon pokemon) + { + var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); + return call != null ? (uint)call.GetArguments()[0]! : null; + } + + /// + /// Helper to extract the damage source from a Pokémon's received Damage calls. + /// + private static DamageSource? GetDamageSource(IPokemon pokemon) + { + var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); + return call != null ? (DamageSource)call.GetArguments()[1]! : null; + } + + /// + /// Bulbapedia: "After Bide is selected, the user will be unable to select a move for an idling period". + /// The first use adds the volatile to the user, which forces the Bide choice on + /// the following turns and records incoming damage. + /// + [Test] + public async Task OnSecondaryEffect_FirstUse_AddsBideEffectToUserVolatile() + { + // Arrange + var (script, move, _, target, userVolatile, _) = CreateTestSetup(); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(userVolatile.Get()).IsNotNull(); + } + + /// + /// Bulbapedia: "Bide will now only endure attacks for two turns." + /// The turn Bide is selected begins the storing period, which spans two more turns; the stored damage is + /// only released on the third execution, so on the second execution nothing is released yet. + /// + [Test] + public async Task OnSecondaryEffect_SecondExecution_DoesNotReleaseStoredDamage() + { + // Arrange + var (script, move, user, target, userVolatile, _) = CreateTestSetup(); + var attacker = CreateAttacker(); + _ = AddStoredBideEffect(userVolatile, user, 1, 50, attacker); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert - still storing, so the attacker has not been damaged + await Assert.That(ReceivedDamage(attacker)).IsFalse(); + } + + /// + /// Bulbapedia: "Bide will do damage equal to twice the damage received during the idling period." + /// + [Test, Arguments(50u, 100u), Arguments(1u, 2u), Arguments(123u, 246u)] + public async Task OnSecondaryEffect_Release_DealsTwiceTheStoredDamage(uint damageTaken, uint expectedDamage) + { + // Arrange + var (script, move, user, target, userVolatile, _) = CreateTestSetup(); + var attacker = CreateAttacker(); + _ = AddStoredBideEffect(userVolatile, user, 2, damageTaken, attacker); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(GetDamageAmount(attacker)).IsEqualTo(expectedDamage); + } + + /// + /// Bulbapedia: "Bide will do damage equal to twice the damage received during the idling period." + /// The released damage is dealt as move damage, not as indirect damage. + /// + [Test] + public async Task OnSecondaryEffect_Release_DealsMoveDamage() + { + // Arrange + var (script, move, user, target, userVolatile, _) = CreateTestSetup(); + var attacker = CreateAttacker(); + _ = AddStoredBideEffect(userVolatile, user, 2, 50, attacker); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(GetDamageSource(attacker)).IsEqualTo(DamageSource.MoveDamage); + } + + /// + /// Bulbapedia: "Bide hits the last Pokémon to attack the user, even if this is an ally." + /// + [Test] + public async Task OnSecondaryEffect_Release_HitsLastPokemonThatAttackedUser() + { + // Arrange + var (script, move, user, target, userVolatile, _) = CreateTestSetup(); + var firstAttacker = CreateAttacker(); + var lastAttacker = CreateAttacker(); + _ = AddStoredBideEffect(userVolatile, user, 2, 50, firstAttacker, lastAttacker); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert - only the last attacker is hit + await Assert.That(ReceivedDamage(lastAttacker)).IsTrue(); + await Assert.That(ReceivedDamage(firstAttacker)).IsFalse(); + } + + /// + /// Bulbapedia: "this attack will hit the last Pokémon to attack the user". + /// A Pokémon that has since left the battlefield cannot be hit by the released damage. + /// + [Test] + public async Task OnSecondaryEffect_Release_AttackerNoLongerOnBattlefield_IsNotDamaged() + { + // Arrange + var (script, move, user, target, userVolatile, _) = CreateTestSetup(); + var departedAttacker = CreateAttacker(false); + _ = AddStoredBideEffect(userVolatile, user, 2, 50, departedAttacker); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(ReceivedDamage(departedAttacker)).IsFalse(); + } + + /// + /// Bulbapedia: "If the user is not directly attacked during the biding period, it will fail on the turn it + /// would have released." + /// + [Test] + public void OnSecondaryEffect_ReleaseWithoutBeingAttacked_MoveFails() + { + // Arrange + var (script, move, user, target, userVolatile, hitData) = CreateTestSetup(); + _ = AddStoredBideEffect(userVolatile, user, 2, 0); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + } + + /// + /// Bulbapedia: "Once a Pokémon has started biding, it cannot choose to switch out until the attack is + /// complete." + /// Once the stored damage is released the attack is complete, so the volatile must + /// be removed from the user; otherwise the user would remain locked into Bide indefinitely through + /// . + /// + [Test] + public async Task OnSecondaryEffect_Release_RemovesBideEffectFromUser() + { + // Arrange + var (script, move, user, target, userVolatile, _) = CreateTestSetup(); + var attacker = CreateAttacker(); + _ = AddStoredBideEffect(userVolatile, user, 2, 50, attacker); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(userVolatile.Get()).IsNull(); + } + + /// + /// Bulbapedia: "Bide will do damage equal to twice the damage received during the idling period." + /// The volatile accumulates every chunk of damage the user takes while storing + /// energy. + /// + [Test] + public async Task BideEffect_OnDamage_AccumulatesDamageTaken() + { + // Arrange + var user = Substitute.For(); + var effect = new BideEffect(user); + + // Act - the user drops from 100 to 60 HP, then from 60 to 50 HP + effect.OnDamage(user, DamageSource.MoveDamage, 100, 60); + effect.OnDamage(user, DamageSource.MoveDamage, 60, 50); + + // Assert + await Assert.That(effect.DamageTaken).IsEqualTo(50u); + } + + /// + /// Bulbapedia: "Bide hits the last Pokémon to attack the user, even if this is an ally." + /// The volatile records every Pokémon that hits the user, in order, so the last + /// attacker can be targeted on release. + /// + [Test] + public async Task BideEffect_OnIncomingHit_RecordsAttacker() + { + // Arrange + var user = Substitute.For(); + var effect = new BideEffect(user); + var incomingMove = Substitute.For(); + var attacker = Substitute.For(); + incomingMove.User.Returns(attacker); + + // Act + effect.OnIncomingHit(incomingMove, user, 0); + + // Assert + await Assert.That(effect.HitBy.LastOrDefault()).IsEqualTo(attacker); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BindTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BindTests.cs new file mode 100644 index 0000000..a6089fb --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BindTests.cs @@ -0,0 +1,257 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Dynamic.ScriptHandling.Registry; +using PkmnLib.Plugin.Gen7.Scripts; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Pokemon; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script and its volatile. +/// Gen VII Bulbapedia behavior (Gen II base plus Gen V/VI deltas): "It also traps the target, preventing +/// switching and escape", "Its effect will last either 4 or 5 turns", and "The end turn damage of Bind is +/// increased from 1/16 to 1/8 of the target's maximum HP." +/// +public class BindTests +{ + /// + /// Test helper script that modifies the Bind duration/damage through the + /// custom trigger, the same way the Grip Claw and Binding Band item scripts do. + /// + [Script(ScriptCategory.Pokemon, "test_modify_bind_trigger")] + private class ModifyBindTrigger : Script, IScriptCustomTrigger + { + private readonly int? _duration; + private readonly float? _damagePercent; + + public ModifyBindTrigger(int? duration = null, float? damagePercent = null) + { + _duration = duration; + _damagePercent = damagePercent; + } + + public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args) + { + if (eventName != CustomTriggers.ModifyBind || args is not CustomTriggers.ModifyBindArgs bindArgs) + return; + if (_duration.HasValue) + bindArgs.Duration = _duration.Value; + if (_damagePercent.HasValue) + bindArgs.DamagePercent = _damagePercent.Value; + } + } + + private static (Bind script, IExecutingMove move, IPokemon user, IPokemon target, IScriptSet targetVolatile) + CreateTestSetup(params Script[] userScripts) + { + var script = new Bind(); + var move = Substitute.For(); + var target = Substitute.For(); + var targetVolatile = Substitute.For(); + target.Volatile.Returns(targetVolatile); + + var user = Substitute.For(); + // RunScriptHook iterates the user's scripts; give the mock a real iterator so the ModifyBind trigger + // pass runs (empty unless the test attaches scripts such as the Grip Claw / Binding Band stand-in). + var containers = userScripts.Select(IEnumerable (s) => new ScriptContainer(s)).ToArray(); + user.GetScripts().Returns(_ => new ScriptIterator(containers)); + move.User.Returns(user); + + return (script, move, user, target, targetVolatile); + } + + /// + /// Helper to extract the instance that was added to the target's volatile scripts. + /// + private static BindEffect? GetAddedEffect(IScriptSet targetVolatile) => targetVolatile.ReceivedCalls() + .Where(c => c.GetMethodInfo().Name == "Add").Select(c => c.GetArguments()[0]).OfType() + .FirstOrDefault(); + + /// + /// Helper to extract the damage amount from the target's first received Damage call. + /// + private static uint? GetDamageAmount(IPokemon pokemon) + { + var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); + return call != null ? (uint)call.GetArguments()[0]! : null; + } + + /// + /// Runs end-of-turn handling on the effect repeatedly and counts how many turns dealt damage to the target. + /// + private static int CountEndTurnDamageTicks(BindEffect effect, IPokemon target, int maxTurns = 10) + { + var battle = Substitute.For(); + for (var i = 0; i < maxTurns; i++) + effect.OnEndTurn(target, battle); + return target.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Damage"); + } + + /// + /// Bulbapedia: "It also traps the target, preventing switching and escape." + /// Using Bind adds the volatile to the target. + /// + [Test] + public void OnSecondaryEffect_AddsBindEffectToTargetVolatile() + { + // Arrange + var (script, move, _, target, targetVolatile) = CreateTestSetup(); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + targetVolatile.Received(1).Add(Arg.Any()); + } + + /// + /// Bulbapedia: "The end turn damage of Bind is increased from 1/16 to 1/8 of the target's maximum HP." + /// Tests several max HP values to ensure proper integer truncation of the 1/8 fraction. + /// + [Test, Arguments(160u, 20u), Arguments(100u, 12u), Arguments(15u, 1u)] + public async Task OnSecondaryEffect_Default_EndTurnDamageIsOneEighthOfTargetMaxHp(uint maxHealth, + uint expectedDamage) + { + // Arrange + var (script, move, _, target, targetVolatile) = CreateTestSetup(); + target.MaxHealth.Returns(maxHealth); + + // Act + script.OnSecondaryEffect(move, target, 0); + var effect = GetAddedEffect(targetVolatile); + await Assert.That(effect).IsNotNull(); + effect!.OnEndTurn(target, Substitute.For()); + + // Assert + await Assert.That(GetDamageAmount(target)).IsEqualTo(expectedDamage); + } + + /// + /// Bulbapedia: "Its effect will last either 4 or 5 turns." + /// The added effect deals its end-of-turn damage for 4 or 5 turns and then ends. + /// + [Test] + public async Task OnSecondaryEffect_Default_EffectLastsFourOrFiveTurns() + { + // Arrange + var (script, move, _, target, targetVolatile) = CreateTestSetup(); + target.MaxHealth.Returns(160u); + + // Act + script.OnSecondaryEffect(move, target, 0); + var effect = GetAddedEffect(targetVolatile); + await Assert.That(effect).IsNotNull(); + var turnsWithDamage = CountEndTurnDamageTicks(effect!, target); + + // Assert + await Assert.That(turnsWithDamage is 4 or 5).IsTrue(); + } + + /// + /// Bulbapedia: "If the user of Bind is holding a Grip Claw, the duration will be 7 turns." + /// The duration change flows through the custom trigger; a script + /// on the user that sets the duration to 7 results in an effect lasting 7 turns. + /// + [Test] + public async Task OnSecondaryEffect_DurationSetToSevenByTrigger_EffectLastsSevenTurns() + { + // Arrange + var (script, move, _, target, targetVolatile) = CreateTestSetup(new ModifyBindTrigger(7)); + target.MaxHealth.Returns(160u); + + // Act + script.OnSecondaryEffect(move, target, 0); + var effect = GetAddedEffect(targetVolatile); + await Assert.That(effect).IsNotNull(); + var turnsWithDamage = CountEndTurnDamageTicks(effect!, target); + + // Assert + await Assert.That(turnsWithDamage).IsEqualTo(7); + } + + /// + /// Bulbapedia: "If the user is holding a Binding Band, the end turn damage of Bind will increase to 1/6 of + /// the target's maximum HP." + /// The damage change flows through the custom trigger; a script on + /// the user that sets the damage to 1/6 results in end-of-turn damage of 1/6 max HP. + /// + [Test, Arguments(60u, 10u), Arguments(48u, 8u)] + public async Task OnSecondaryEffect_DamageSetToOneSixthByTrigger_EndTurnDamageIsOneSixth(uint maxHealth, + uint expectedDamage) + { + // Arrange + var (script, move, _, target, targetVolatile) = CreateTestSetup(new ModifyBindTrigger(damagePercent: 1f / 6f)); + target.MaxHealth.Returns(maxHealth); + + // Act + script.OnSecondaryEffect(move, target, 0); + var effect = GetAddedEffect(targetVolatile); + await Assert.That(effect).IsNotNull(); + effect!.OnEndTurn(target, Substitute.For()); + + // Assert + await Assert.That(GetDamageAmount(target)).IsEqualTo(expectedDamage); + } + + /// + /// Bulbapedia: "It also traps the target, preventing switching and escape." + /// While the effect is active, the target cannot switch out. + /// + [Test] + public async Task BindEffect_WhileActive_PreventsSwitching() + { + // Arrange + var target = Substitute.For(); + var effect = new BindEffect(target, 5, 1f / 8f); + var prevent = false; + + // Act + effect.PreventSelfSwitch(Substitute.For(), ref prevent); + + // Assert + await Assert.That(prevent).IsTrue(); + } + + /// + /// Bulbapedia: "It also traps the target, preventing switching and escape." + /// While the effect is active, the target cannot flee. + /// + [Test] + public async Task BindEffect_WhileActive_PreventsRunningAway() + { + // Arrange + var target = Substitute.For(); + var effect = new BindEffect(target, 5, 1f / 8f); + var prevent = false; + + // Act + effect.PreventSelfRunAway(Substitute.For(), ref prevent); + + // Assert + await Assert.That(prevent).IsTrue(); + } + + /// + /// Bulbapedia: "Its effect will last either 4 or 5 turns." + /// Once the duration has run out, the target is no longer prevented from switching. + /// + [Test] + public async Task BindEffect_AfterDurationExpires_NoLongerPreventsSwitching() + { + // Arrange + var target = Substitute.For(); + target.MaxHealth.Returns(160u); + var effect = new BindEffect(target, 1, 1f / 8f); + + // Act - the single remaining turn elapses + effect.OnEndTurn(target, Substitute.For()); + var prevent = false; + effect.PreventSelfSwitch(Substitute.For(), ref prevent); + + // Assert + await Assert.That(prevent).IsFalse(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BlockTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BlockTests.cs new file mode 100644 index 0000000..6e466f7 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BlockTests.cs @@ -0,0 +1,119 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Dynamic.ScriptHandling.Registry; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Pokemon; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Block prevents the target from switching out or fleeing (including via +/// Teleport)." From Generation VI onward: "Block no longer affects Ghost-type Pokémon." +/// +public class BlockTests +{ + private static (Block script, IExecutingMove move, IPokemon target, ScriptSet targetVolatile) CreateTestSetup() + { + var script = new Block(); + var move = Substitute.For(); + var user = Substitute.For(); + move.User.Returns(user); + + var target = Substitute.For(); + // Use a real script set so the volatile script added by Block can be inspected afterwards. + var targetVolatile = new ScriptSet(target); + target.Volatile.Returns(targetVolatile); + target.GetScripts().Returns(_ => new ScriptIterator(new List>())); + move.GetHitData(target, 0).Returns(Substitute.For()); + + return (script, move, target, targetVolatile); + } + + /// + /// Bulbapedia: "Block prevents the target from switching out or fleeing (including via Teleport)." + /// Hitting the target attaches the volatile script to it. + /// + [Test] + public async Task OnSecondaryEffect_Hit_AddsBlockEffectToTarget() + { + // Arrange + var (script, move, target, targetVolatile) = CreateTestSetup(); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue(); + } + + /// + /// Bulbapedia: "Block prevents the target from switching out". + /// The applied by the move prevents the target's switch choices through + /// . + /// + [Test] + public async Task OnSecondaryEffect_AppliedEffect_PreventsTargetFromSwitchingOut() + { + // Arrange + var (script, move, target, targetVolatile) = CreateTestSetup(); + script.OnSecondaryEffect(move, target, 0); + await Assert.That(targetVolatile.TryGet(out var effect)).IsTrue(); + + // Act + var prevent = false; + effect!.PreventSelfSwitch(Substitute.For(), ref prevent); + + // Assert + await Assert.That(prevent).IsTrue(); + } + + /// + /// Bulbapedia: "Block prevents the target from [...] fleeing (including via Teleport)." + /// The applied by the move prevents the target's flee choices through + /// . + /// + [Test] + public async Task OnSecondaryEffect_AppliedEffect_PreventsTargetFromFleeing() + { + // Arrange + var (script, move, target, targetVolatile) = CreateTestSetup(); + script.OnSecondaryEffect(move, target, 0); + await Assert.That(targetVolatile.TryGet(out var effect)).IsTrue(); + + // Act + var prevent = false; + effect!.PreventSelfRunAway(Substitute.For(), ref prevent); + + // Assert + await Assert.That(prevent).IsTrue(); + } + + /// + /// Bulbapedia (Generation VI onward): "Block no longer affects Ghost-type Pokémon." + /// Using Block on a Ghost-type target should not trap it, so no may be added. + /// + [Test] + public async Task OnSecondaryEffect_GhostTypeTarget_DoesNotTrapTarget() + { + // Arrange + var (script, move, target, targetVolatile) = CreateTestSetup(); + var library = LibraryHelpers.LoadLibrary(); + await Assert.That(library.StaticLibrary.Types.TryGetTypeIdentifier("ghost", out var ghostType)).IsTrue(); + target.Types.Returns(new[] { ghostType }); + + var battle = Substitute.For(); + battle.Library.Returns(library); + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + move.User.BattleData.Returns(battleData); + target.BattleData.Returns(battleData); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsFalse(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BounceTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BounceTests.cs new file mode 100644 index 0000000..096d675 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BounceTests.cs @@ -0,0 +1,327 @@ +using PkmnLib.Dynamic.Events; +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Dynamic.ScriptHandling.Registry; +using PkmnLib.Plugin.Gen7.Common; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Pokemon; +using PkmnLib.Static.Moves; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "The user gains the Sky-High status on the turn this move is used, then +/// attacks on the following turn, inflicting damage with a 30% chance of paralyzing the target. While in +/// Sky-High status, the user is invulnerable to most moves, but can still be hit by Gust, Twister, Thunder, +/// and Sky Uppercut", where (from Generation V onward) for Gust and Twister "the damage dealt will be doubled". +/// +public class BounceTests +{ + private static (Bounce script, IExecutingMove move, IPokemon user, ScriptSet userVolatile, IMoveChoice moveChoice, + IBattleRandom random) CreateTestSetup() + { + var script = new Bounce(); + var move = Substitute.For(); + var user = Substitute.For(); + // Use a real script set so the charge volatile added by Bounce can be inspected afterwards. + var userVolatile = new ScriptSet(user); + user.Volatile.Returns(userVolatile); + user.GetScripts().Returns(_ => new ScriptIterator(new List>())); + move.User.Returns(user); + + var moveChoice = Substitute.For(); + move.MoveChoice.Returns(moveChoice); + + var battle = Substitute.For(); + var random = Substitute.For(); + battle.Random.Returns(random); + battle.EventHook.Returns(new EventHook()); + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + user.BattleData.Returns(battleData); + + return (script, move, user, userVolatile, moveChoice, random); + } + + /// + /// Bulbapedia: "The user gains the Sky-High status on the turn this move is used, then attacks on the + /// following turn". On the first turn the move is prevented from executing. + /// + [Test] + public async Task PreventMove_FirstUse_PreventsMoveForChargeTurn() + { + // Arrange + var (script, move, _, _, _, _) = CreateTestSetup(); + var prevent = false; + + // Act + script.PreventMove(move, ref prevent); + + // Assert + await Assert.That(prevent).IsTrue(); + } + + /// + /// Bulbapedia: "The user gains the Sky-High status on the turn this move is used". + /// The charge turn attaches the volatile script to the user. + /// + [Test] + public async Task PreventMove_FirstUse_AddsChargeBounceEffectToUser() + { + // Arrange + var (script, move, _, userVolatile, _, _) = CreateTestSetup(); + var prevent = false; + + // Act + script.PreventMove(move, ref prevent); + + // Assert + await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsTrue(); + } + + /// + /// Bulbapedia: "The user gains the Sky-High status on the turn this move is used". + /// The charge turn is marked on the so the + /// knows this choice was the charging turn. + /// + [Test] + public async Task PreventMove_FirstUse_MarksMoveChoiceAsChargeTurn() + { + // Arrange + var (script, move, _, _, moveChoice, _) = CreateTestSetup(); + var prevent = false; + + // Act + script.PreventMove(move, ref prevent); + + // Assert + moveChoice.Received(1).SetAdditionalData(new StringKey("bounce_charge"), true); + await Assert.That(prevent).IsTrue(); + } + + /// + /// Bulbapedia: "then attacks on the following turn". When the user already has the + /// from the charge turn, the move is not prevented again. + /// + [Test] + public async Task PreventMove_SecondTurn_DoesNotPreventMove() + { + // Arrange + var (script, move, user, userVolatile, _, _) = CreateTestSetup(); + userVolatile.Add(new ChargeBounceEffect(user)); + var prevent = false; + + // Act + script.PreventMove(move, ref prevent); + + // Assert + await Assert.That(prevent).IsFalse(); + } + + /// + /// Bulbapedia: "then attacks on the following turn". Once the attack executes, the Sky-High state + /// () is removed from the user. + /// + [Test] + public async Task OnBeforeMove_ChargeCompleted_RemovesChargeBounceEffect() + { + // Arrange + var (script, move, user, userVolatile, _, _) = CreateTestSetup(); + userVolatile.Add(new ChargeBounceEffect(user)); + + // Act + script.OnBeforeMove(move); + + // Assert + await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsFalse(); + } + + /// + /// Bulbapedia: "inflicting damage with a 30% chance of paralyzing the target." + /// When the effect chance roll succeeds, the target is paralyzed. + /// + [Test] + public async Task OnSecondaryEffect_EffectChanceSucceeds_ParalyzesTarget() + { + // Arrange + var (script, move, user, _, _, random) = CreateTestSetup(); + var target = Substitute.For(); + random.EffectChance(30, move, target, 0).Returns(true); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + target.Received(1).SetStatus(new StringKey("paralyzed"), user); + await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsTrue(); + } + + /// + /// Bulbapedia: "inflicting damage with a 30% chance of paralyzing the target." + /// When the effect chance roll fails, the target is not paralyzed. + /// + [Test] + public async Task OnSecondaryEffect_EffectChanceFails_DoesNotParalyzeTarget() + { + // Arrange + var (script, move, _, _, _, random) = CreateTestSetup(); + var target = Substitute.For(); + random.EffectChance(30, move, target, 0).Returns(false); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse(); + } + + /// + /// Bulbapedia: "a 30% chance of paralyzing the target." + /// The paralysis roll is made with exactly a 30% chance. + /// + [Test] + public async Task OnSecondaryEffect_ParalysisRoll_UsesThirtyPercentChance() + { + // Arrange + var (script, move, _, _, _, random) = CreateTestSetup(); + var target = Substitute.For(); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + random.Received(1).EffectChance(30, move, target, 0); + await Assert.That(random.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "EffectChance")).IsTrue(); + } + + /// + /// Technical test: outside of battle (no battle data) the secondary effect does nothing and does not throw. + /// + [Test] + public async Task OnSecondaryEffect_NoBattleData_DoesNotParalyzeTarget() + { + // Arrange + var (script, move, user, _, _, _) = CreateTestSetup(); + user.BattleData.Returns((IPokemonBattleData?)null); + var target = Substitute.For(); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse(); + } + + /// + /// Bulbapedia: "While in Sky-High status, the user is invulnerable to most moves". + /// The added by the charge turn blocks incoming hits from moves that + /// cannot hit semi-invulnerable flying Pokémon. + /// + [Test] + public async Task BlockIncomingHit_MoveCannotHitSkyHighUser_BlocksHit() + { + // Arrange + var (script, move, user, userVolatile, _, _) = CreateTestSetup(); + var prevent = false; + script.PreventMove(move, ref prevent); + await Assert.That(userVolatile.TryGet(out var effect)).IsTrue(); + + var incomingMove = Substitute.For(); + var incomingMoveData = Substitute.For(); + incomingMoveData.HasFlag(MoveFlags.HitFlying).Returns(false); + incomingMove.UseMove.Returns(incomingMoveData); + + // Act + var block = false; + effect!.BlockIncomingHit(incomingMove, user, 0, ref block); + + // Assert + await Assert.That(block).IsTrue(); + } + + /// + /// Bulbapedia: "but can still be hit by Gust, Twister, Thunder, and Sky Uppercut". + /// Moves flagged as able to hit semi-invulnerable flying Pokémon are not blocked by the + /// . + /// + [Test] + public async Task BlockIncomingHit_MoveCanHitSkyHighUser_DoesNotBlockHit() + { + // Arrange + var (script, move, user, userVolatile, _, _) = CreateTestSetup(); + var prevent = false; + script.PreventMove(move, ref prevent); + await Assert.That(userVolatile.TryGet(out var effect)).IsTrue(); + + var incomingMove = Substitute.For(); + var incomingMoveData = Substitute.For(); + incomingMoveData.HasFlag(MoveFlags.HitFlying).Returns(true); + incomingMove.UseMove.Returns(incomingMoveData); + + // Act + var block = false; + effect!.BlockIncomingHit(incomingMove, user, 0, ref block); + + // Assert + await Assert.That(block).IsFalse(); + } + + /// + /// Bulbapedia (Generation V onward): "If the user is hit by Gust or Twister while the user has the + /// Sky-High status, the damage dealt will be doubled". Gust and Twister carry the + /// flag, so their damage against the Sky-High user doubles. + /// + [Test] + public async Task ChangeIncomingMoveDamage_GustLikeMove_DamageIsDoubled() + { + // Arrange + var (script, move, user, userVolatile, _, _) = CreateTestSetup(); + var prevent = false; + script.PreventMove(move, ref prevent); + await Assert.That(userVolatile.TryGet(out var effect)).IsTrue(); + + var incomingMove = Substitute.For(); + var incomingMoveData = Substitute.For(); + incomingMoveData.HasFlag(MoveFlags.HitFlying).Returns(true); + incomingMoveData.HasFlag(MoveFlags.EffectiveAgainstFly).Returns(true); + incomingMove.UseMove.Returns(incomingMoveData); + uint damage = 100; + + // Act + effect!.ChangeIncomingMoveDamage(incomingMove, user, 0, ref damage); + + // Assert + await Assert.That(damage).IsEqualTo(200u); + } + + /// + /// Bulbapedia (Generation V onward): only for Gust and Twister "the damage dealt will be doubled". + /// Other moves that can hit the Sky-High user (e.g. Thunder, Sky Uppercut) deal regular damage; they do + /// not carry the flag. + /// + [Test] + public async Task ChangeIncomingMoveDamage_ThunderLikeMove_DamageIsUnchanged() + { + // Arrange + var (script, move, user, userVolatile, _, _) = CreateTestSetup(); + var prevent = false; + script.PreventMove(move, ref prevent); + await Assert.That(userVolatile.TryGet(out var effect)).IsTrue(); + + var incomingMove = Substitute.For(); + var incomingMoveData = Substitute.For(); + incomingMoveData.HasFlag(MoveFlags.HitFlying).Returns(true); + incomingMoveData.HasFlag(MoveFlags.EffectiveAgainstFly).Returns(false); + incomingMove.UseMove.Returns(incomingMoveData); + uint damage = 100; + + // Act + effect!.ChangeIncomingMoveDamage(incomingMove, user, 0, ref damage); + + // Assert + await Assert.That(damage).IsEqualTo(100u); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrickBreakTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrickBreakTests.cs new file mode 100644 index 0000000..6390690 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrickBreakTests.cs @@ -0,0 +1,180 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Side; +using PkmnLib.Static.Utils; +using TUnit.Assertions.AssertConditions.Throws; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Brick Break removes Light Screen and Reflect", from Generation IV onward +/// "from the target's side of the field, even if the target is an ally", and from Generation V onwards +/// "Brick Break can also remove Aurora Veil." +/// +public class BrickBreakTests +{ + /// + /// Creates a mocked Pokémon whose is the given side. + /// + private static IPokemon CreatePokemonOnSide(IBattleSide side) + { + var pokemon = Substitute.For(); + var battleData = Substitute.For(); + battleData.BattleSide.Returns(side); + pokemon.BattleData.Returns(battleData); + return pokemon; + } + + /// + /// Creates a fully mocked test setup where the user targets a Pokémon on the opposing side. + /// + private static (BrickBreak script, IExecutingMove move, IPokemon user, IScriptSet userSideScripts, IScriptSet + targetSideScripts) CreateTestSetup() + { + var script = new BrickBreak(); + var move = Substitute.For(); + + var userSideScripts = Substitute.For(); + var userSide = Substitute.For(); + userSide.VolatileScripts.Returns(userSideScripts); + var targetSideScripts = Substitute.For(); + var targetSide = Substitute.For(); + targetSide.VolatileScripts.Returns(targetSideScripts); + + var user = CreatePokemonOnSide(userSide); + move.User.Returns(user); + var target = CreatePokemonOnSide(targetSide); + move.Targets.Returns(new IPokemon?[] { target }); + + return (script, move, user, userSideScripts, targetSideScripts); + } + + /// + /// Bulbapedia (Generation IV onward): "Brick Break now removes Light Screen and Reflect from the target's + /// side of the field, even if the target is an ally." The screen effects are removed from the target's + /// side before the move hits. The script names match and + /// . + /// + [Test, Arguments("reflect"), Arguments("light_screen")] + public async Task OnBeforeMove_ScreenOnTargetSide_RemovesScreenFromTargetSide(string screenScriptName) + { + // Arrange + var (script, move, _, _, targetSideScripts) = CreateTestSetup(); + + // Act + script.OnBeforeMove(move); + + // Assert + targetSideScripts.Received(1).Remove(new StringKey(screenScriptName)); + await Assert.That(targetSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue(); + } + + /// + /// Bulbapedia (Generation V onwards): "Brick Break can also remove Aurora Veil." + /// The is removed from the target's side as well. + /// + [Test] + public async Task OnBeforeMove_AuroraVeilOnTargetSide_RemovesAuroraVeilFromTargetSide() + { + // Arrange + var (script, move, _, _, targetSideScripts) = CreateTestSetup(); + + // Act + script.OnBeforeMove(move); + + // Assert + targetSideScripts.Received(1).Remove(new StringKey("aurora_veil")); + await Assert.That(targetSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue(); + } + + /// + /// Bulbapedia (Generation IV onward): "Brick Break now removes Light Screen and Reflect from the target's + /// side of the field". When the target is an opponent, screens on the user's own side stay up. + /// + [Test] + public async Task OnBeforeMove_ScreensOnUserSide_DoesNotRemoveScreensFromUserSide() + { + // Arrange + var (script, move, _, userSideScripts, _) = CreateTestSetup(); + + // Act + script.OnBeforeMove(move); + + // Assert + await Assert.That(userSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsFalse(); + } + + /// + /// Bulbapedia (Generation IV onward): "Brick Break now removes Light Screen and Reflect from the target's + /// side of the field, even if the target is an ally." An ally shares the user's side, so targeting an ally + /// removes the screens from the user's own side. + /// + [Test, + TestFailing("BrickBreak.OnBeforeMove excludes the user's own side unconditionally, so targeting an " + + "ally removes nothing (Bulbapedia: 'removes Light Screen and Reflect from the target's " + + "side of the field, even if the target is an ally')")] + public async Task OnBeforeMove_AllyTargetOnUserSide_RemovesScreensFromUserSide() + { + // Arrange + var (script, move, user, userSideScripts, _) = CreateTestSetup(); + var ally = CreatePokemonOnSide(user.BattleData!.BattleSide); + move.Targets.Returns(new IPokemon?[] { ally }); + + // Act + script.OnBeforeMove(move); + + // Assert + await Assert.That(userSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue(); + } + + /// + /// Bulbapedia: "Brick Break removes Light Screen and Reflect [...], then inflicts damage." + /// When no screens are up there is nothing to remove; the move simply proceeds without error and the + /// sides remain unaffected. + /// + [Test] + public async Task OnBeforeMove_NoScreensPresent_LeavesSidesUnchanged() + { + // Arrange + var script = new BrickBreak(); + var move = Substitute.For(); + // Real, empty script sets so the removals (if any wrongly happen) would be observable as mutations. + var userSideScripts = new ScriptSet(Substitute.For()); + var targetSideScripts = new ScriptSet(Substitute.For()); + var userSide = Substitute.For(); + userSide.VolatileScripts.Returns(userSideScripts); + var targetSide = Substitute.For(); + targetSide.VolatileScripts.Returns(targetSideScripts); + var user = CreatePokemonOnSide(userSide); + move.User.Returns(user); + var target = CreatePokemonOnSide(targetSide); + move.Targets.Returns(new IPokemon?[] { target }); + + // Act + script.OnBeforeMove(move); + + // Assert + await Assert.That(userSideScripts.Count).IsEqualTo(0); + await Assert.That(targetSideScripts.Count).IsEqualTo(0); + } + + /// + /// Technical test: without any targets (e.g. outside of battle) the script does nothing and does not throw. + /// + [Test] + public async Task OnBeforeMove_NoTargets_DoesNotThrow() + { + // Arrange + var script = new BrickBreak(); + var move = Substitute.For(); + var user = Substitute.For(); + user.BattleData.Returns((IPokemonBattleData?)null); + move.User.Returns(user); + move.Targets.Returns(Array.Empty()); + + // Act & Assert + await Assert.That(() => script.OnBeforeMove(move)).ThrowsNothing(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrineTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrineTests.cs new file mode 100644 index 0000000..ec573dc --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrineTests.cs @@ -0,0 +1,101 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Behavior is verified against the Bulbapedia page for Brine. +/// +public class BrineTests +{ + /// + /// Creates a fully mocked test setup for Brine tests, with a target whose max HP + /// () and are configured. + /// + private static (Brine brine, IExecutingMove move, IPokemon target) CreateTestSetup(uint maxHp, uint currentHp) + { + var brine = new Brine(); + var move = Substitute.For(); + var target = Substitute.For(); + target.BoostedStats.Returns(new StatisticSet(maxHp, 0, 0, 0, 0, 0)); + target.CurrentHealth.Returns(currentHp); + return (brine, move, target); + } + + /// + /// Bulbapedia: "Brine inflicts damage, and its power doubles to 130 when the target is at or below + /// 50% health." + /// At exactly half HP, the base power of 65 doubles to 130. + /// + [Test] + public async Task ChangeBasePower_TargetAtExactlyHalfHp_BasePowerDoubles() + { + // Arrange + var (brine, move, target) = CreateTestSetup(100, 50); + ushort basePower = 65; + + // Act + brine.ChangeBasePower(move, target, 0, ref basePower); + + // Assert + await Assert.That(basePower).IsEqualTo((ushort)130); + } + + /// + /// Bulbapedia: "its power doubles to 130 when the target is at or below 50% health." + /// Tests several HP fractions at or below half, including odd max HP values where 50% is not + /// a whole number (50/101 ≈ 49.5% is below half). + /// + [Test, Arguments(100u, 49u), Arguments(100u, 1u), Arguments(200u, 100u), Arguments(101u, 50u)] + public async Task ChangeBasePower_TargetAtOrBelowHalfHp_BasePowerDoubles(uint maxHp, uint currentHp) + { + // Arrange + var (brine, move, target) = CreateTestSetup(maxHp, currentHp); + ushort basePower = 65; + + // Act + brine.ChangeBasePower(move, target, 0, ref basePower); + + // Assert + await Assert.That(basePower).IsEqualTo((ushort)130); + } + + /// + /// Bulbapedia: "its power doubles to 130 when the target is at or below 50% health." + /// Above 50% health the power must remain unchanged, including one HP above half and odd max HP + /// values where 51/101 ≈ 50.5% is just above half. + /// + [Test, Arguments(100u, 51u), Arguments(100u, 100u), Arguments(101u, 51u), Arguments(200u, 101u)] + public async Task ChangeBasePower_TargetAboveHalfHp_BasePowerUnchanged(uint maxHp, uint currentHp) + { + // Arrange + var (brine, move, target) = CreateTestSetup(maxHp, currentHp); + ushort basePower = 65; + + // Act + brine.ChangeBasePower(move, target, 0, ref basePower); + + // Assert + await Assert.That(basePower).IsEqualTo((ushort)65); + } + + /// + /// Technical test: doubling an already very high base power must clamp to + /// instead of overflowing. + /// + [Test] + public async Task ChangeBasePower_DoublingWouldOverflow_ClampsToMax() + { + // Arrange + var (brine, move, target) = CreateTestSetup(100, 50); + ushort basePower = ushort.MaxValue - 100; + + // Act + brine.ChangeBasePower(move, target, 0, ref basePower); + + // Assert + await Assert.That(basePower).IsEqualTo(ushort.MaxValue); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BugBiteTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BugBiteTests.cs new file mode 100644 index 0000000..c5d4583 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BugBiteTests.cs @@ -0,0 +1,243 @@ +using PkmnLib.Dynamic.Events; +using PkmnLib.Dynamic.Libraries; +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static; +using PkmnLib.Static.Moves; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Behavior is verified against the Bulbapedia page for Bug Bite. +/// +public class BugBiteTests +{ + private const string BerryEffectName = "test_berry_effect"; + + /// + /// Minimal that records whether its effect was applied through + /// . + /// + private class RecordingItemScript : ItemScript + { + public RecordingItemScript(IItem item) : base(item) + { + } + + public bool WasUsed { get; private set; } + + /// + public override bool IsItemUsable => true; + + /// + public override void OnUse(EventHook eventHook) => WasUsed = true; + } + + /// + /// Creates a fully mocked test setup for BugBite tests. The battle's is a + /// real resolver with a single registered item script constructor for , so + /// eating a Berry runs a that the test can inspect. + /// + private static (BugBite bugBite, IExecutingMove move, IPokemon target, IHitData hitData, List + createdItemScripts) CreateTestSetup(IItem? targetHeldItem, bool canSteal = true) + { + var bugBite = new BugBite(); + + var createdItemScripts = new List(); + var resolver = new ScriptResolver(new Dictionary<(ScriptCategory, StringKey), Func