diff --git a/.claude/skills/bulbapedia-tests/SKILL.md b/.claude/skills/bulbapedia-tests/SKILL.md new file mode 100644 index 0000000..57d34db --- /dev/null +++ b/.claude/skills/bulbapedia-tests/SKILL.md @@ -0,0 +1,139 @@ +--- +name: bulbapedia-tests +description: Fetches move/ability descriptions from Bulbapedia and translates them into atomic TUnit tests in the Gen7 test project. Use when asked to write Bulbapedia-based tests for moves or abilities, e.g. "test tackle" or "write tests for all moves starting with a". +argument-hint: +allowed-tools: WebFetch(domain:bulbapedia.bulbagarden.net), Read, Glob, Grep, Write(Plugins/PkmnLib.Plugin.Gen7.Tests/**), Edit(Plugins/PkmnLib.Plugin.Gen7.Tests/**), Bash(dotnet build:*), Bash(dotnet test:*) +--- + +# Bulbapedia → Gen7 unit tests + +Generate atomic unit tests for Gen7 move/ability script implementations, using their +Bulbapedia descriptions as the source of truth for correct behavior. + +## Hard constraints (tool whitelist) + +- **Web access**: only `https://bulbapedia.bulbagarden.net/...` via WebFetch. Never fetch any + other domain. +- **File modifications**: only inside `Plugins/PkmnLib.Plugin.Gen7.Tests/`. Never modify the + implementation project (`Plugins/PkmnLib.Plugin.Gen7/`) or anything else — even if you find a + bug there. Reading any file in the repo is allowed and encouraged. +- **Shell**: only `dotnet build` and `dotnet test`. + +## Step 1 — Resolve targets from the argument + +The argument describes what to test. Interpret it and resolve it to a list of implemented +script classes: + +- A single name (`tackle`, `ability intimidate`) → that one script. Default to moves; the words + "ability"/"abilities" switch to abilities. +- A pattern (`all moves starting with a`, `abilities containing 'veil'`) → enumerate with Glob + against the implementation folders: + - Moves: `Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/*.cs` + - Abilities: `Plugins/PkmnLib.Plugin.Gen7/Scripts/Abilities/*.cs` +- Only test scripts that actually exist in those folders. If a requested target has no + implementation file, don't invent one — list it as skipped in the final report. +- Skip base/helper classes (e.g. `BaseChargeMove.cs`) unless explicitly requested; they are + covered through the concrete moves. + +For each target, check whether `Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/{Moves,Abilities}/Tests.cs` +already exists. If so, extend it with missing behaviors (via Edit) instead of overwriting it, +and don't duplicate behaviors that already have a test. + +## Step 2 — Fetch the Bulbapedia description + +This fetch procedure is verified to work; follow it exactly. + +**Primary: WebFetch the rendered wiki page.** URL conventions (spaces become underscores, +each word capitalized as on Bulbapedia; the `(move)` suffix is lowercase, `(Ability)` is +capitalized): + +- Moves: `https://bulbapedia.bulbagarden.net/wiki/_(move)` — e.g. `Tackle_(move)`, `Fury_Cutter_(move)` +- Abilities: `https://bulbapedia.bulbagarden.net/wiki/_(Ability)` — e.g. `Intimidate_(Ability)` + +Prompt the fetch with: *"Extract the full Effect section verbatim, including all per-generation +subsections."* The Effect section is structured as a base description plus generation-delta +subsections, e.g. for Tackle: base text "Tackle inflicts damage and has no secondary effect.", +then `Generations V and VI`: "power was increased from 35 to 50...", then +`Generation VII onwards`: "power was decreased from 50 to 40." + +**Fallback / exact wording: the MediaWiki API, also via WebFetch.** When the summarized page +extract seems lossy or you need the exact sentence to quote, fetch the raw wikitext (the Effect +section is normally section index 1): + +- `https://bulbapedia.bulbagarden.net/w/api.php?action=parse&page=Tackle_(move)&prop=wikitext&format=json§ion=1` + +**Name resolution**: if unsure of the exact page title, use the API's opensearch: + +- `https://bulbapedia.bulbagarden.net/w/api.php?action=opensearch&search=Fury_C&limit=5&format=json` + → returns matching titles and URLs (e.g. `Fury Cutter (move)`). + +**Deriving Gen VII behavior**: this codebase implements **Generation VII**. The Effect section +is written as deltas — start from the base description and apply every subsection whose range +includes Gen VII (`Generation VII onwards`, `Generations V to VII`, `Generation VI onwards`, ...); +ignore subsections entirely outside it (`Generation I`, `Generation VIII`, side games like +Legends: Arceus). Ability pages often use range headers like "Generations V to VII" — take the +range containing VII. Also note interactions the page calls out (e.g. "does not activate if a +Pokémon with Damp is on the field") — those are test cases too. + +## Step 3 — Read the implementation + +Read the script class under `Plugins/PkmnLib.Plugin.Gen7/Scripts/{Moves,Abilities}/.cs` +to learn which hooks it overrides (`ChangeBasePower`, `OnIncomingHit`, `OnFaint`, ...) and what +its public surface looks like. Tests drive those hook methods directly — do not test behavior +the script class isn't responsible for (type effectiveness, generic damage formula, or static +data like base power and accuracy; `DataTests/` already covers move/ability data). + +## Step 4 — Write atomic tests + +Follow the existing conventions — `Scripts/Moves/DrainTests.cs` and +`Scripts/Abilities/AftermathTests.cs` are the reference examples (`DrainTests.cs` also shows +`` usage and a real `[TestFailing]`), `Scripts/Moves/AcrobaticsTests.cs` a minimal one: + +- File: `Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/Tests.cs` or + `.../Scripts/Abilities/Tests.cs`. +- Namespace: `PkmnLib.Plugin.Gen7.Tests.Scripts.Moves` / `...Abilities`. `TUnit` and + `NSubstitute` are global usings; add the `PkmnLib.*` usings the test needs. +- **One behavior per test** (atomic). Name: `Method_Scenario_ExpectedOutcome`, e.g. + `ChangeBasePower_UserNotHoldingItem_BasePowerDoubles`. +- Each test gets an XML doc comment **quoting the Bulbapedia sentence it verifies**: + `/// Bulbapedia: "When a Pokémon with this Ability faints from damage caused by a move that makes contact, ..."`. +- In doc comments, refer to other classes and members with `` tags instead of plain text — + the class-level comment references the script under test + (`/// Tests for the ability.`), and test comments reference types or + members they mention (`/// If the attacker has no , ...`). +- Arrange/Act/Assert with `Substitute.For()`, `Substitute.For()`, etc. + Assertions are TUnit style: `await Assert.That(actual).IsEqualTo(expected)`. +- Use `[Arguments(...)]` rows for numeric variations (damage fractions, rounding/integer + division, overflow guards). +- Cover the negative cases the description implies (non-contact move → no trigger, holding an + item → no boost, attacker already fainted, null `BattleData`, ...). +- `LibraryHelpers.LoadLibrary()` is available when a test genuinely needs the full Gen7 library + (e.g. looking up real move data); prefer pure mocks otherwise. + +## Step 5 — Build, run, and mark failures + +1. Build: `dotnet build Plugins/PkmnLib.Plugin.Gen7.Tests`. Compile errors are always + test-authoring mistakes — fix them; the attribute cannot rescue non-compiling code. +2. Run the new tests: `dotnet test Plugins/PkmnLib.Plugin.Gen7.Tests` (TUnit runs on + Microsoft.Testing.Platform; to run a single class, pass + `-- --treenode-filter '/*/*/Tests/*'`). +3. For each failing test, decide who is wrong: + - **The test is wrong** (misread the page, wrong mock setup, asserting behavior of a + different generation) → fix the test and rerun. + - **The implementation is wrong** (test correctly encodes Gen VII Bulbapedia behavior but the + script does something else) → keep the correct assertion and add the marker so it is + skipped but tracked: + ```csharp + [Test, TestFailing("Aftermath deals 1/2 max HP instead of 1/4 (Bulbapedia: '¼ of its own maximum HP')")] + ``` + `TestFailingAttribute` lives in `PkmnLib.Plugin.Gen7.Tests` and takes the failure reason as + its argument — describe concretely what the implementation does wrong. + - Never make a wrong test pass by asserting the buggy behavior, and never touch the + implementation to make a test pass. +4. Rerun until every test either passes or carries `[TestFailing(...)]`. + +## Step 6 — Report + +Summarize: tests written per target, tests marked `[TestFailing]` with their reasons (these are +implementation bugs the user needs to fix), and targets skipped because no Gen7 implementation +exists. diff --git a/PkmnLib.Dynamic/Models/BattleChoiceQueue.cs b/PkmnLib.Dynamic/Models/BattleChoiceQueue.cs index 94ced8e..436a6f9 100644 --- a/PkmnLib.Dynamic/Models/BattleChoiceQueue.cs +++ b/PkmnLib.Dynamic/Models/BattleChoiceQueue.cs @@ -93,7 +93,7 @@ public class BattleChoiceQueue : IDeepCloneable if (index == -1) return false; if (index == _currentIndex) - return true; + return false; // Already next var choice = _choices[index]; _choices[index] = null; // Push all choices back diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AcupressureTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AcupressureTests.cs new file mode 100644 index 0000000..6073220 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AcupressureTests.cs @@ -0,0 +1,160 @@ +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: "Acupressure chooses one of the target's stats at random and raises it by +/// two stages. It can raise either the target's Attack, Defense, Special Attack, Special Defense, Speed, +/// accuracy, or evasion stat but will not attempt to raise a stat that is already maximized, meaning that +/// the move will fail if all stats are maximized." +/// +public class AcupressureTests +{ + private static (Acupressure script, IExecutingMove move, IPokemon target, IBattleRandom random, IHitData hitData) + CreateTestSetup() + { + var script = new Acupressure(); + var move = Substitute.For(); + var target = Substitute.For(); + var hitData = Substitute.For(); + move.GetHitData(target, 0).Returns(hitData); + + var random = Substitute.For(); + var battle = Substitute.For(); + battle.Random.Returns(random); + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + + var user = Substitute.For(); + user.BattleData.Returns(battleData); + move.User.Returns(user); + + target.StatBoost.Returns(new StatBoostStatisticSet()); + + return (script, move, target, random, hitData); + } + + /// + /// Helper to extract the stat argument of a ChangeStatBoost call received by the target. + /// + private static object?[]? GetStatBoostCallArgs(IPokemon target) + { + var call = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost"); + return call?.GetArguments(); + } + + /// + /// Bulbapedia: "Acupressure chooses one of the target's stats at random and raises it by two stages." + /// When the random roll selects Attack, the target's Attack is raised by exactly two stages. + /// + [Test] + public async Task OnSecondaryEffect_RandomSelectsAttack_RaisesAttackByTwoStages() + { + // Arrange + var (script, move, target, random, _) = CreateTestSetup(); + // Attack is the first entry in the pool of raisable stats (HP is excluded). + random.GetInt(Arg.Any(), Arg.Any()).Returns(0); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + var args = GetStatBoostCallArgs(target); + await Assert.That(args).IsNotNull(); + await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.Attack); + await Assert.That((sbyte)args[1]!).IsEqualTo((sbyte)2); + } + + /// + /// Bulbapedia: "It can raise either the target's Attack, Defense, Special Attack, Special Defense, Speed, + /// accuracy, or evasion stat" — HP is not one of the raisable stats. When the random roll returns the lowest + /// value of its range, the selected stat must be Attack, not HP. + /// + [Test] + public async Task OnSecondaryEffect_RandomMinimumRoll_SelectsAttackNotHp() + { + // Arrange + var (script, move, target, random, _) = CreateTestSetup(); + // Return the inclusive lower bound of whatever range is requested. + random.GetInt(Arg.Any(), Arg.Any()).Returns(ci => ci.ArgAt(0)); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + var args = GetStatBoostCallArgs(target); + await Assert.That(args).IsNotNull(); + await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.Attack); + } + + /// + /// Bulbapedia: "It can raise either the target's Attack, Defense, Special Attack, Special Defense, Speed, + /// accuracy, or evasion stat". Accuracy and evasion must be selectable: when the random roll returns the + /// highest value of its range, the selected stat should be one of the stats beyond Speed in the + /// enum (Evasion or Accuracy). + /// + [Test] + public async Task OnSecondaryEffect_RandomMaximumRoll_CanSelectAccuracyOrEvasion() + { + // Arrange + var (script, move, target, random, _) = CreateTestSetup(); + // Return the highest value of whatever range is requested (upper bound is exclusive). + random.GetInt(Arg.Any(), Arg.Any()).Returns(ci => ci.ArgAt(1) - 1); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert - the highest selectable stat should be Evasion or Accuracy + var args = GetStatBoostCallArgs(target); + await Assert.That(args).IsNotNull(); + var stat = (Statistic)args![0]!; + await Assert.That(stat is Statistic.Evasion or Statistic.Accuracy).IsTrue(); + } + + /// + /// Bulbapedia: "the move will fail if all stats are maximized." All raisable stats (Attack through accuracy; + /// HP boosts cannot be raised by any move and stay at 0) are at +6, so the move must fail without attempting + /// a stat change. + /// + [Test] + public async Task OnSecondaryEffect_AllRaisableStatsMaximized_Fails() + { + // Arrange + var (script, move, target, _, hitData) = CreateTestSetup(); + var statBoost = new StatBoostStatisticSet(0, 6, 6, 6, 6, 6) { Evasion = 6, Accuracy = 6 }; + target.StatBoost.Returns(statBoost); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse(); + } + + /// + /// Bulbapedia: "will not attempt to raise a stat that is already maximized". With the target's Attack at +6 + /// and other stats available, Attack must never be the raised stat, regardless of what the random roll + /// returns. The rolls cover every index of the six-stat selection pool that remains. + /// + [Test, Arguments(0), Arguments(1), Arguments(2), Arguments(3), Arguments(4), Arguments(5)] + public async Task OnSecondaryEffect_StatAlreadyMaximized_NeverRaisesThatStat(int roll) + { + // Arrange + var (script, move, target, random, _) = CreateTestSetup(); + var statBoost = new StatBoostStatisticSet(0, 6, 0, 0, 0, 0); + target.StatBoost.Returns(statBoost); + random.GetInt(Arg.Any(), Arg.Any()).Returns(roll); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert - a stat is raised, but never the maximized Attack + var args = GetStatBoostCallArgs(target); + await Assert.That(args).IsNotNull(); + await Assert.That((Statistic)args![0]!).IsNotEqualTo(Statistic.Attack); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AfterYouTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AfterYouTests.cs new file mode 100644 index 0000000..755ea69 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AfterYouTests.cs @@ -0,0 +1,140 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Plugin.Gen7.Scripts.Moves; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior (Generations V to VII): "The target will move next on the current turn, +/// ignoring priority. [...] It fails if the target has already moved on the same turn. [...] After You fails +/// if the order remains the same after using After You." +/// +public class AfterYouTests +{ + private static IMoveChoice CreateChoice(IPokemon user, uint speed) + { + var choice = Substitute.For(); + choice.User.Returns(user); + choice.Speed.Returns(speed); + return choice; + } + + private static (AfterYou script, IExecutingMove move, IHitData hitData) CreateTestSetup(BattleChoiceQueue? queue) + { + var script = new AfterYou(); + var move = Substitute.For(); + var hitData = Substitute.For(); + move.GetHitData(Arg.Any(), Arg.Any()).Returns(hitData); + + var battle = Substitute.For(); + battle.ChoiceQueue.Returns(queue); + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + + var user = Substitute.For(); + user.BattleData.Returns(battleData); + move.User.Returns(user); + + return (script, move, hitData); + } + + /// + /// Bulbapedia: "The target will move next on the current turn, ignoring priority." + /// The target's choice is moved in front of a faster Pokémon's remaining choice, and the move does not fail. + /// + [Test] + public async Task OnSecondaryEffect_TargetLaterInQueue_TargetMovesNext() + { + // Arrange + var user = Substitute.For(); + var target = Substitute.For(); + var other = Substitute.For(); + // Sorted by speed: user (100), other (75), target (50) + var queue = new BattleChoiceQueue([ + CreateChoice(user, 100), CreateChoice(other, 75), CreateChoice(target, 50), + ]); + // The user's own choice is executing, so it has been dequeued already. + queue.Dequeue(); + + var (script, move, hitData) = CreateTestSetup(queue); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert - the target is now the next Pokémon to move, ahead of the faster one + await Assert.That(queue.Peek()!.User).IsEqualTo(target); + hitData.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: "It fails if the target has already moved on the same turn." + /// The target's choice is no longer in the queue, so the move fails. + /// + [Test] + public void OnSecondaryEffect_TargetAlreadyMoved_Fails() + { + // Arrange + var user = Substitute.For(); + var target = Substitute.For(); + // Sorted by speed: target (100), user (50) + var queue = new BattleChoiceQueue([ + CreateChoice(target, 100), CreateChoice(user, 50), + ]); + // The target already moved this turn, and the user's own choice is executing. + queue.Dequeue(); + queue.Dequeue(); + + var (script, move, hitData) = CreateTestSetup(queue); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + } + + /// + /// Bulbapedia: "After You fails if the order remains the same after using After You." + /// The target was already the next Pokémon to move, so using After You changes nothing and the move + /// must fail. + /// + [Test] + public void OnSecondaryEffect_TargetAlreadyNext_Fails() + { + // Arrange + var user = Substitute.For(); + var target = Substitute.For(); + // Sorted by speed: user (100), target (50) + var queue = new BattleChoiceQueue([ + CreateChoice(user, 100), CreateChoice(target, 50), + ]); + // The user's own choice is executing; the target is already next. + queue.Dequeue(); + + var (script, move, hitData) = CreateTestSetup(queue); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + } + + /// + /// Technical test: when the battle has no active choice queue, the script returns without throwing + /// and without failing the hit. + /// + [Test] + public void OnSecondaryEffect_NoChoiceQueue_DoesNothing() + { + // Arrange + var (script, move, hitData) = CreateTestSetup(null); + + // Act + script.OnSecondaryEffect(move, Substitute.For(), 0); + + // Assert + hitData.DidNotReceive().Fail(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AssistTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AssistTests.cs new file mode 100644 index 0000000..10bcbf1 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AssistTests.cs @@ -0,0 +1,205 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +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 Assist user executes a move that is randomly selected from all the +/// eligible moves known by other Pokémon in the same party. If the same move is known by multiple Pokémon, +/// each of them counts [...]. Assist can call a move that currently has no PP or is known by a fainted +/// Pokémon. [...] If the other Pokémon in the user's party do not know any eligible moves, Assist fails." +/// +public class AssistTests +{ + private static IPokemon CreatePokemonWithMoves(params string[] moveNames) + { + var pokemon = Substitute.For(); + var moves = moveNames.Select(name => + { + var learned = Substitute.For(); + var data = Substitute.For(); + data.Name.Returns(new StringKey(name)); + learned.MoveData.Returns(data); + return (ILearnedMove?)learned; + }).ToArray(); + pokemon.Moves.Returns(moves); + return pokemon; + } + + private static (Assist script, IMoveChoice choice, IPokemon user, IBattleRandom random) CreateTestSetup( + params IPokemon?[] otherPartyMembers) + { + var script = new Assist(); + var user = CreatePokemonWithMoves("tackle"); + + 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 random = Substitute.For(); + var battle = Substitute.For(); + battle.Parties.Returns(new[] { battleParty }); + battle.Random.Returns(random); + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + user.BattleData.Returns(battleData); + + var choice = Substitute.For(); + choice.User.Returns(user); + + return (script, choice, user, random); + } + + /// + /// Bulbapedia: "The Assist user executes a move that is randomly selected from all the eligible moves known + /// by other Pokémon in the same party." + /// + [Test] + public async Task ChangeMove_AllyKnowsEligibleMove_SelectsThatMove() + { + // Arrange + var (script, choice, _, random) = CreateTestSetup(CreatePokemonWithMoves("growl")); + random.GetInt(Arg.Any()).Returns(0); + StringKey moveName = "assist"; + + // Act + script.ChangeMove(choice, ref moveName); + + // Assert + await Assert.That(moveName).IsEqualTo(new StringKey("growl")); + choice.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: "If the other Pokémon in the user's party do not know any eligible moves, Assist fails." + /// The user's own moves are not eligible: with no other party members, the move fails even though the + /// user itself knows a copyable move. + /// + [Test] + public async Task ChangeMove_NoOtherPartyMembers_Fails() + { + // Arrange + var (script, choice, _, _) = CreateTestSetup(); + StringKey moveName = "assist"; + + // Act + script.ChangeMove(choice, ref moveName); + + // Assert + choice.Received(1).Fail(); + await Assert.That(moveName).IsEqualTo(new StringKey("assist")); + } + + /// + /// Bulbapedia lists moves that Assist cannot call (e.g. Protect, Counter, moves with a semi-invulnerable + /// turn). An ally that only knows ineligible moves provides no options, so Assist fails. + /// + [Test] + public async Task ChangeMove_AllyOnlyKnowsIneligibleMoves_Fails() + { + // Arrange + var (script, choice, _, _) = CreateTestSetup(CreatePokemonWithMoves("protect", "counter", "dig")); + StringKey moveName = "assist"; + + // Act + script.ChangeMove(choice, ref moveName); + + // Assert + choice.Received(1).Fail(); + await Assert.That(moveName).IsEqualTo(new StringKey("assist")); + } + + /// + /// Bulbapedia: "If the same move is known by multiple Pokémon, each of them counts and therefore the move + /// is more likely to be called." Two allies knowing Tackle plus one knowing Growl yields three entries in + /// the random selection pool. + /// + [Test] + public void ChangeMove_SameMoveKnownByMultipleAllies_EachCounts() + { + // Arrange + var (script, choice, _, random) = CreateTestSetup(CreatePokemonWithMoves("pound"), + CreatePokemonWithMoves("pound"), CreatePokemonWithMoves("growl")); + random.GetInt(Arg.Any()).Returns(0); + StringKey moveName = "assist"; + + // Act + script.ChangeMove(choice, ref moveName); + + // Assert - the selection pool contains three moves (pound twice, growl once) + random.Received(1).GetInt(3); + choice.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: "Assist can call a move that [...] is known by a fainted Pokémon." + /// + [Test] + public async Task ChangeMove_AllyIsFainted_MoveStillEligible() + { + // Arrange + var faintedAlly = CreatePokemonWithMoves("growl"); + faintedAlly.IsUsable.Returns(false); + var (script, choice, _, random) = CreateTestSetup(faintedAlly); + random.GetInt(Arg.Any()).Returns(0); + StringKey moveName = "assist"; + + // Act + script.ChangeMove(choice, ref moveName); + + // Assert + await Assert.That(moveName).IsEqualTo(new StringKey("growl")); + choice.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: "Assist can call a move that currently has no PP". + /// + [Test] + public async Task ChangeMove_AllyMoveHasNoPp_MoveStillEligible() + { + // Arrange + var ally = CreatePokemonWithMoves("growl"); + ally.Moves[0]!.CurrentPp.Returns((byte)0); + var (script, choice, _, random) = CreateTestSetup(ally); + random.GetInt(Arg.Any()).Returns(0); + StringKey moveName = "assist"; + + // Act + script.ChangeMove(choice, ref moveName); + + // Assert + await Assert.That(moveName).IsEqualTo(new StringKey("growl")); + choice.DidNotReceive().Fail(); + } + + /// + /// Technical test: outside of battle (no battle data) the script returns without failing the choice. + /// + [Test] + public async Task ChangeMove_NoBattleData_DoesNothing() + { + // Arrange + var script = new Assist(); + var user = Substitute.For(); + user.BattleData.Returns((IPokemonBattleData?)null); + var choice = Substitute.For(); + choice.User.Returns(user); + StringKey moveName = "assist"; + + // Act + script.ChangeMove(choice, ref moveName); + + // Assert + choice.DidNotReceive().Fail(); + await Assert.That(moveName).IsEqualTo(new StringKey("assist")); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AttractTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AttractTests.cs new file mode 100644 index 0000000..36627ed --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AttractTests.cs @@ -0,0 +1,108 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Pokemon; +using PkmnLib.Static.Species; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "The target becomes infatuated with the user if they have opposite genders. +/// [...] Attract has no effect if the user and target have the same gender, or if either of them is a +/// gender-unknown Pokémon (such as Magnemite). It also fails when used on a Pokémon that is already infatuated." +/// +public class AttractTests +{ + private static (Attract script, IExecutingMove move, IPokemon target, IScriptSet targetVolatile, IHitData hitData) + CreateTestSetup(Gender userGender, Gender targetGender) + { + var script = new Attract(); + var move = Substitute.For(); + var target = Substitute.For(); + var hitData = Substitute.For(); + move.GetHitData(target, 0).Returns(hitData); + + var targetVolatile = Substitute.For(); + target.Volatile.Returns(targetVolatile); + target.Gender.Returns(targetGender); + + var user = Substitute.For(); + user.Gender.Returns(userGender); + move.User.Returns(user); + + return (script, move, target, targetVolatile, hitData); + } + + /// + /// Bulbapedia: "The target becomes infatuated with the user if they have opposite genders." + /// + [Test, Arguments(Gender.Male, Gender.Female), Arguments(Gender.Female, Gender.Male)] + public void OnSecondaryEffect_OppositeGenders_TargetBecomesInfatuated(Gender userGender, Gender targetGender) + { + // Arrange + var (script, move, target, targetVolatile, hitData) = CreateTestSetup(userGender, targetGender); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + targetVolatile.Received(1).Add(Arg.Any()); + hitData.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: "Attract has no effect if the user and target have the same gender". + /// + [Test, Arguments(Gender.Male, Gender.Male), Arguments(Gender.Female, Gender.Female)] + public void OnSecondaryEffect_SameGender_Fails(Gender userGender, Gender targetGender) + { + // Arrange + var (script, move, target, targetVolatile, hitData) = CreateTestSetup(userGender, targetGender); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + targetVolatile.DidNotReceive().Add(Arg.Any