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: "Heart Swap switches the user's stat stages with the target's stat /// stages." /// public class HeartSwapTests { private static (HeartSwap script, IExecutingMove move, IBattlePokemon user, IBattlePokemon target) CreateTestSetup( StatBoostStatisticSet userBoosts, StatBoostStatisticSet targetBoosts) { var script = new HeartSwap(); var move = Substitute.For(); var user = Substitute.For(); user.StatBoost.Returns(userBoosts); move.User.Returns(user); var target = Substitute.For(); target.StatBoost.Returns(targetBoosts); return (script, move, user, target); } /// /// Helper to extract the arguments of the received ChangeStatBoost call for the given statistic, or /// null when that stat was not changed. Received-call inspection is used instead of NSubstitute /// argument matchers because the trailing EventBatchId parameter cannot be bound by Arg.Any. /// private static object?[]? GetStatBoostArgs(IBattlePokemon pokemon, Statistic stat) => pokemon.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").Select(c => c.GetArguments()) .FirstOrDefault(args => (Statistic)args[0]! == stat); /// /// Bulbapedia: "Heart Swap switches the user's stat stages with the target's stat stages." The user /// must end up at the target's stage and vice versa, so the user is changed by /// (target - user) and the target by (user - target). /// [Test, Arguments((sbyte)2, (sbyte)-3, (sbyte)-5, (sbyte)5), Arguments((sbyte)-1, (sbyte)4, (sbyte)5, (sbyte)-5), Arguments((sbyte)6, (sbyte)-6, (sbyte)-12, (sbyte)12), Arguments((sbyte)0, (sbyte)1, (sbyte)1, (sbyte)-1)] public async Task OnSecondaryEffect_DifferentAttackStages_StagesAreSwapped(sbyte userAttack, sbyte targetAttack, sbyte expectedUserChange, sbyte expectedTargetChange) { // Arrange var (script, move, user, target) = CreateTestSetup(new StatBoostStatisticSet(0, userAttack, 0, 0, 0, 0), new StatBoostStatisticSet(0, targetAttack, 0, 0, 0, 0)); // Act script.OnSecondaryEffect(move, target, 0); // Assert var userArgs = GetStatBoostArgs(user, Statistic.Attack); var targetArgs = GetStatBoostArgs(target, Statistic.Attack); await Assert.That(userArgs).IsNotNull(); await Assert.That((sbyte)userArgs![1]!).IsEqualTo(expectedUserChange); await Assert.That(targetArgs).IsNotNull(); await Assert.That((sbyte)targetArgs![1]!).IsEqualTo(expectedTargetChange); } /// /// Bulbapedia: "Heart Swap switches the user's stat stages with the target's stat stages." — every /// battle stat stage (Attack, Defense, Special Attack, Special Defense, Speed) is exchanged at once. /// [Test] public async Task OnSecondaryEffect_AllBattleStatStages_AreSwapped() { // Arrange var (script, move, user, target) = CreateTestSetup(new StatBoostStatisticSet(0, 2, -1, 3, -4, 6), new StatBoostStatisticSet(0, -2, 4, 0, 1, -6)); // Act script.OnSecondaryEffect(move, target, 0); // Assert - each user change is (target - user), each target change is (user - target) foreach (var (stat, userStage, targetStage) in new[] { (Statistic.Attack, (sbyte)2, (sbyte)-2), (Statistic.Defense, (sbyte)-1, (sbyte)4), (Statistic.SpecialAttack, (sbyte)3, (sbyte)0), (Statistic.SpecialDefense, (sbyte)-4, (sbyte)1), (Statistic.Speed, (sbyte)6, (sbyte)-6), }) { var userArgs = GetStatBoostArgs(user, stat); var targetArgs = GetStatBoostArgs(target, stat); await Assert.That(userArgs).IsNotNull(); await Assert.That((sbyte)userArgs![1]!).IsEqualTo((sbyte)(targetStage - userStage)); await Assert.That(targetArgs).IsNotNull(); await Assert.That((sbyte)targetArgs![1]!).IsEqualTo((sbyte)(userStage - targetStage)); } } /// /// Bulbapedia: "Heart Swap switches the user's stat stages with the target's stat stages." — stat /// stages include and , which are /// exchanged as well. /// [Test] public async Task OnSecondaryEffect_EvasionAndAccuracyStages_AreSwapped() { // Arrange var (script, move, user, target) = CreateTestSetup( new StatBoostStatisticSet(0, 0, 0, 0, 0, 0) { Evasion = 2, Accuracy = -1 }, new StatBoostStatisticSet(0, 0, 0, 0, 0, 0) { Evasion = -3, Accuracy = 4 }); // Act script.OnSecondaryEffect(move, target, 0); // Assert var userEvasionArgs = GetStatBoostArgs(user, Statistic.Evasion); var userAccuracyArgs = GetStatBoostArgs(user, Statistic.Accuracy); await Assert.That(userEvasionArgs).IsNotNull(); await Assert.That((sbyte)userEvasionArgs![1]!).IsEqualTo((sbyte)-5); await Assert.That(userAccuracyArgs).IsNotNull(); await Assert.That((sbyte)userAccuracyArgs![1]!).IsEqualTo((sbyte)5); } /// /// Bulbapedia: swapping identical stat stages is a net-zero exchange — neither Pokémon's stages are /// modified. /// [Test] public async Task OnSecondaryEffect_EqualStages_NoStagesAreChanged() { // Arrange var (script, move, user, target) = CreateTestSetup(new StatBoostStatisticSet(0, 2, -1, 0, 3, 1), new StatBoostStatisticSet(0, 2, -1, 0, 3, 1)); // Act script.OnSecondaryEffect(move, target, 0); // Assert await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse(); await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse(); } /// /// Bulbapedia: the exchange is unconditional — the changes are applied with force so clamping or /// prevention effects cannot block the swap, and only the user's own change counts as /// self-inflicted. /// [Test] public async Task OnSecondaryEffect_Always_ChangesAreForced() { // Arrange var (script, move, user, target) = CreateTestSetup(new StatBoostStatisticSet(0, 2, 0, 0, 0, 0), new StatBoostStatisticSet(0, -1, 0, 0, 0, 0)); // Act script.OnSecondaryEffect(move, target, 0); // Assert var userArgs = GetStatBoostArgs(user, Statistic.Attack); var targetArgs = GetStatBoostArgs(target, Statistic.Attack); await Assert.That((bool)userArgs![3]!).IsTrue(); // forced await Assert.That((bool)targetArgs![3]!).IsTrue(); // forced await Assert.That((bool)userArgs[2]!).IsTrue(); // self-inflicted for the user await Assert.That((bool)targetArgs[2]!).IsFalse(); // not self-inflicted for the target } }