Adds more tests, fixes
All checks were successful
Build / Build (push) Successful in 1m7s

This commit is contained in:
2026-07-04 13:03:54 +02:00
parent 8fbf22a987
commit f9878e76ba
14 changed files with 1564 additions and 8 deletions

View File

@@ -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: <target, e.g. "tackle" | "ability intimidate" | "all moves starting with a">
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}/<ClassName>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_Name>_(move)` — e.g. `Tackle_(move)`, `Fury_Cutter_(move)`
- Abilities: `https://bulbapedia.bulbagarden.net/wiki/<Ability_Name>_(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&section=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}/<ClassName>.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
`<see>` usage and a real `[TestFailing]`), `Scripts/Moves/AcrobaticsTests.cs` a minimal one:
- File: `Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/<ClassName>Tests.cs` or
`.../Scripts/Abilities/<ClassName>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 `<see>` tags instead of plain text —
the class-level comment references the script under test
(`/// Tests for the <see cref="Aftermath"/> ability.`), and test comments reference types or
members they mention (`/// If the attacker has no <see cref="IPokemon.BattleData"/>, ...`).
- Arrange/Act/Assert with `Substitute.For<IExecutingMove>()`, `Substitute.For<IPokemon>()`, 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 '/*/*/<ClassName>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.

View File

@@ -93,7 +93,7 @@ public class BattleChoiceQueue : IDeepCloneable
if (index == -1) if (index == -1)
return false; return false;
if (index == _currentIndex) if (index == _currentIndex)
return true; return false; // Already next
var choice = _choices[index]; var choice = _choices[index];
_choices[index] = null; _choices[index] = null;
// Push all choices back // Push all choices back

View File

@@ -0,0 +1,160 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Acupressure"/> 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."
/// </summary>
public class AcupressureTests
{
private static (Acupressure script, IExecutingMove move, IPokemon target, IBattleRandom random, IHitData hitData)
CreateTestSetup()
{
var script = new Acupressure();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var random = Substitute.For<IBattleRandom>();
var battle = Substitute.For<IBattle>();
battle.Random.Returns(random);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
move.User.Returns(user);
target.StatBoost.Returns(new StatBoostStatisticSet());
return (script, move, target, random, hitData);
}
/// <summary>
/// Helper to extract the stat argument of a ChangeStatBoost call received by the target.
/// </summary>
private static object?[]? GetStatBoostCallArgs(IPokemon target)
{
var call = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
return call?.GetArguments();
}
/// <summary>
/// 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.
/// </summary>
[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<int>(), Arg.Any<int>()).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);
}
/// <summary>
/// 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.
/// </summary>
[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<int>(), Arg.Any<int>()).Returns(ci => ci.ArgAt<int>(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);
}
/// <summary>
/// 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
/// <see cref="Statistic"/> enum (Evasion or Accuracy).
/// </summary>
[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<int>(), Arg.Any<int>()).Returns(ci => ci.ArgAt<int>(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();
}
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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<int>(), Arg.Any<int>()).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);
}
}

View File

@@ -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;
/// <summary>
/// Tests for the <see cref="AfterYou"/> 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."
/// </summary>
public class AfterYouTests
{
private static IMoveChoice CreateChoice(IPokemon user, uint speed)
{
var choice = Substitute.For<IMoveChoice>();
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<IExecutingMove>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(Arg.Any<IPokemon>(), Arg.Any<byte>()).Returns(hitData);
var battle = Substitute.For<IBattle>();
battle.ChoiceQueue.Returns(queue);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, hitData);
}
/// <summary>
/// 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.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetLaterInQueue_TargetMovesNext()
{
// Arrange
var user = Substitute.For<IPokemon>();
var target = Substitute.For<IPokemon>();
var other = Substitute.For<IPokemon>();
// 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();
}
/// <summary>
/// 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.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetAlreadyMoved_Fails()
{
// Arrange
var user = Substitute.For<IPokemon>();
var target = Substitute.For<IPokemon>();
// 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();
}
/// <summary>
/// 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.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetAlreadyNext_Fails()
{
// Arrange
var user = Substitute.For<IPokemon>();
var target = Substitute.For<IPokemon>();
// 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();
}
/// <summary>
/// Technical test: when the battle has no active choice queue, the script returns without throwing
/// and without failing the hit.
/// </summary>
[Test]
public void OnSecondaryEffect_NoChoiceQueue_DoesNothing()
{
// Arrange
var (script, move, hitData) = CreateTestSetup(null);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
hitData.DidNotReceive().Fail();
}
}

View File

@@ -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;
/// <summary>
/// Tests for the <see cref="Assist"/> 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."
/// </summary>
public class AssistTests
{
private static IPokemon CreatePokemonWithMoves(params string[] moveNames)
{
var pokemon = Substitute.For<IPokemon>();
var moves = moveNames.Select(name =>
{
var learned = Substitute.For<ILearnedMove>();
var data = Substitute.For<IMoveData>();
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<IPokemon?> { user };
members.AddRange(otherPartyMembers);
var party = Substitute.For<IPokemonParty>();
party.GetEnumerator().Returns(_ => members.GetEnumerator());
var battleParty = Substitute.For<IBattleParty>();
battleParty.Party.Returns(party);
var random = Substitute.For<IBattleRandom>();
var battle = Substitute.For<IBattle>();
battle.Parties.Returns(new[] { battleParty });
battle.Random.Returns(random);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
return (script, choice, user, random);
}
/// <summary>
/// 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."
/// </summary>
[Test]
public async Task ChangeMove_AllyKnowsEligibleMove_SelectsThatMove()
{
// Arrange
var (script, choice, _, random) = CreateTestSetup(CreatePokemonWithMoves("growl"));
random.GetInt(Arg.Any<int>()).Returns(0);
StringKey moveName = "assist";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("growl"));
choice.DidNotReceive().Fail();
}
/// <summary>
/// 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.
/// </summary>
[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"));
}
/// <summary>
/// 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.
/// </summary>
[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"));
}
/// <summary>
/// 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.
/// </summary>
[Test]
public void ChangeMove_SameMoveKnownByMultipleAllies_EachCounts()
{
// Arrange
var (script, choice, _, random) = CreateTestSetup(CreatePokemonWithMoves("pound"),
CreatePokemonWithMoves("pound"), CreatePokemonWithMoves("growl"));
random.GetInt(Arg.Any<int>()).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();
}
/// <summary>
/// Bulbapedia: "Assist can call a move that [...] is known by a fainted Pokémon."
/// </summary>
[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<int>()).Returns(0);
StringKey moveName = "assist";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("growl"));
choice.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "Assist can call a move that currently has no PP".
/// </summary>
[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<int>()).Returns(0);
StringKey moveName = "assist";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("growl"));
choice.DidNotReceive().Fail();
}
/// <summary>
/// Technical test: outside of battle (no battle data) the script returns without failing the choice.
/// </summary>
[Test]
public async Task ChangeMove_NoBattleData_DoesNothing()
{
// Arrange
var script = new Assist();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
var choice = Substitute.For<IMoveChoice>();
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"));
}
}

View File

@@ -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;
/// <summary>
/// Tests for the <see cref="Attract"/> 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."
/// </summary>
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<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var targetVolatile = Substitute.For<IScriptSet>();
target.Volatile.Returns(targetVolatile);
target.Gender.Returns(targetGender);
var user = Substitute.For<IPokemon>();
user.Gender.Returns(userGender);
move.User.Returns(user);
return (script, move, target, targetVolatile, hitData);
}
/// <summary>
/// Bulbapedia: "The target becomes infatuated with the user if they have opposite genders."
/// </summary>
[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<Infatuated>());
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "Attract has no effect if the user and target have the same gender".
/// </summary>
[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<Script>());
}
/// <summary>
/// Bulbapedia: "Attract has no effect [...] if either of them is a gender-unknown Pokémon (such as Magnemite)."
/// Covers a genderless user, a genderless target, and both being genderless.
/// </summary>
[Test, Arguments(Gender.Genderless, Gender.Female), Arguments(Gender.Male, Gender.Genderless),
Arguments(Gender.Genderless, Gender.Genderless)]
public void OnSecondaryEffect_GenderlessPokemon_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<Script>());
}
/// <summary>
/// Bulbapedia: "It also fails when used on a Pokémon that is already infatuated."
/// </summary>
[Test]
public void OnSecondaryEffect_TargetAlreadyInfatuated_Fails()
{
// Arrange
var (script, move, target, targetVolatile, hitData) = CreateTestSetup(Gender.Male, Gender.Female);
targetVolatile.Contains(new StringKey("infatuated")).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
}
}

View File

@@ -0,0 +1,178 @@
using PkmnLib.Dynamic.Models;
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.Side;
using PkmnLib.Plugin.Gen7.Scripts.Weather;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="AuroraVeil"/> move script.
/// Gen VII Bulbapedia behavior: "Aurora Veil reduces the damage done to the user by physical and special
/// moves for five turns [...]. The move cannot be activated unless hail [...] is present. [...] Holding
/// Light Clay extends the duration from 5 to 8 turns."
/// </summary>
public class AuroraVeilTests
{
/// <summary>
/// Test helper script that extends the Aurora Veil duration through the custom trigger, the same way the
/// Light Clay item script does.
/// </summary>
[Script(ScriptCategory.Pokemon, "test_aurora_veil_duration_extender")]
private class DurationExtender : Script, IScriptCustomTrigger
{
public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args)
{
if (eventName == CustomTriggers.AuroraVeilDuration && args is CustomTriggers.AuroraVeilDurationArgs d)
d.Duration = 8;
}
}
private static (AuroraVeil script, IExecutingMove move, IPokemon target, IPokemon user, IScriptSet sideScripts,
IHitData hitData) CreateTestSetup(StringKey? weather)
{
var script = new AuroraVeil();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var battle = Substitute.For<IBattle>();
battle.WeatherName.Returns(weather);
var sideScripts = Substitute.For<IScriptSet>();
// Mimic the real ScriptSet: invoke the factory and hand back a container holding the new script.
sideScripts.StackOrAdd(Arg.Any<StringKey>(), Arg.Any<Func<Script?>>())
.Returns(ci => new ScriptContainer(ci.Arg<Func<Script?>>()()!));
var side = Substitute.For<IBattleSide>();
side.VolatileScripts.Returns(sideScripts);
battle.Sides.Returns(new[] { side });
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
battleData.SideIndex.Returns((byte)0);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
move.User.Returns(user);
return (script, move, target, user, sideScripts, hitData);
}
/// <summary>
/// Helper to extract the effect script instance created through StackOrAdd on the side's volatile scripts.
/// </summary>
private static AuroraVeilEffect? GetAddedEffect(IScriptSet sideScripts)
{
var call = sideScripts.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "StackOrAdd");
if (call == null)
return null;
var factory = (Func<Script?>)call.GetArguments()[1]!;
return factory() as AuroraVeilEffect;
}
/// <summary>
/// Bulbapedia: "The move cannot be activated unless hail [...] is present."
/// Without any weather, the move fails and no effect is added to the side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoWeather_Fails()
{
// Arrange
var (script, move, target, _, sideScripts, hitData) = CreateTestSetup(null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(sideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd")).IsFalse();
}
/// <summary>
/// Bulbapedia: "The move cannot be activated unless hail [...] is present."
/// Under a different weather (e.g. rain), the move also fails.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonHailWeather_Fails()
{
// Arrange
var (script, move, target, _, sideScripts, hitData) = CreateTestSetup(new StringKey("rain"));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(sideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd")).IsFalse();
}
/// <summary>
/// Bulbapedia: "Aurora Veil reduces the damage done to the user by physical and special moves for five
/// turns". During hail, the Aurora Veil effect is added to the user's side with a duration of 5 turns.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Hail_AddsEffectWithFiveTurns()
{
// Arrange
var (script, move, target, _, sideScripts, hitData) = CreateTestSetup(ScriptUtils.ResolveName<Hail>());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var effect = GetAddedEffect(sideScripts);
await Assert.That(effect).IsNotNull();
await Assert.That(effect!.NumberOfTurns).IsEqualTo(5);
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "If a Light Clay is held when Aurora Veil is used, it will extend the duration of Aurora
/// Veil from 5 to 8 turns." The duration extension flows through the AuroraVeilDuration custom trigger;
/// a script on the user that sets the duration to 8 results in an effect lasting 8 turns.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DurationExtendedByTrigger_AddsEffectWithEightTurns()
{
// Arrange
var (script, move, target, user, sideScripts, _) = CreateTestSetup(ScriptUtils.ResolveName<Hail>());
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>
{
new ScriptContainer(new DurationExtender()),
}));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - the created effect is initialized with the extended duration
var call = sideScripts.ReceivedCalls().First(c => c.GetMethodInfo().Name == "StackOrAdd");
var effect = (AuroraVeilEffect)((Func<Script?>)call.GetArguments()[1]!)()!;
await Assert.That(effect.NumberOfTurns).IsEqualTo(8);
}
/// <summary>
/// Technical test: outside of battle (no battle data) the script returns without throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoBattleData_DoesNothing()
{
// Arrange
var script = new AuroraVeil();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - no hit data was touched
await Assert.That(move.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "GetHitData")).IsFalse();
}
}

View File

@@ -0,0 +1,170 @@
using PkmnLib.Dynamic.Events;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Autotomize"/> move script.
/// Gen VII Bulbapedia behavior: "Autotomize raises the user's Speed stat by two stages and (if the user
/// successfully changes its Speed) decreases its weight by 220 lbs. (100 kg). If the user successfully
/// changes its weight, the message '&lt;Pokémon&gt; became nimble!' is displayed. Autotomize cannot decrease
/// the user's weight below the minimum 0.2 lbs (0.1 kg); if the user's weight would drop below the minimum,
/// it becomes the minimum instead. Weight loss from Autotomize stacks".
/// </summary>
public class AutotomizeTests
{
private static (Autotomize script, IExecutingMove move, IPokemon user, IScriptSet userVolatile, EventHook eventHook)
CreateTestSetup(float weightInKg, bool speedRaiseSucceeds, AutotomizeEffect? existingEffect = null)
{
var script = new Autotomize();
var move = Substitute.For<IExecutingMove>();
var eventHook = new EventHook();
var battle = Substitute.For<IBattle>();
battle.EventHook.Returns(eventHook);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var userVolatile = Substitute.For<IScriptSet>();
userVolatile.Get<AutotomizeEffect>().Returns(existingEffect);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
user.Volatile.Returns(userVolatile);
user.WeightInKg.Returns(weightInKg);
user.ChangeStatBoost(Arg.Any<Statistic>(), Arg.Any<sbyte>(), Arg.Any<bool>(), Arg.Any<bool>())
.Returns(speedRaiseSucceeds);
move.User.Returns(user);
return (script, move, user, userVolatile, eventHook);
}
private static bool ReceivedStackOrAdd(IScriptSet scriptSet) =>
scriptSet.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd");
/// <summary>
/// Bulbapedia: "Autotomize raises the user's Speed stat by two stages". The stat change is self-inflicted.
/// </summary>
[Test]
public void OnSecondaryEffect_RaisesUserSpeedByTwoStages()
{
// Arrange
var (script, move, user, _, _) = CreateTestSetup(100f, true);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
user.Received(1).ChangeStatBoost(Statistic.Speed, 2, true, false);
}
/// <summary>
/// Bulbapedia: "(if the user successfully changes its Speed) decreases its weight by 220 lbs. (100 kg).
/// If the user successfully changes its weight, the message '&lt;Pokémon&gt; became nimble!' is displayed."
/// </summary>
[Test]
public async Task OnSecondaryEffect_SpeedRaiseSucceeds_AddsWeightReductionAndShowsMessage()
{
// Arrange
var (script, move, _, userVolatile, eventHook) = CreateTestSetup(100f, true);
DialogEvent? capturedEvent = null;
eventHook.Handler += (_, args) =>
{
if (args is DialogEvent dialogEvent)
capturedEvent = dialogEvent;
};
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsTrue();
await Assert.That(capturedEvent).IsNotNull();
await Assert.That(capturedEvent!.Message).IsEqualTo("pokemon_became_nimble");
}
/// <summary>
/// Bulbapedia: the weight decrease only happens "if the user successfully changes its Speed". When the
/// Speed stat cannot be raised (already at +6), no weight reduction is applied and no message is shown.
/// </summary>
[Test]
public async Task OnSecondaryEffect_SpeedRaiseFails_NoWeightReductionOrMessage()
{
// Arrange
var (script, move, _, userVolatile, eventHook) = CreateTestSetup(100f, false);
var eventFired = false;
eventHook.Handler += (_, _) => eventFired = true;
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsFalse();
await Assert.That(eventFired).IsFalse();
}
/// <summary>
/// Bulbapedia: "Autotomize cannot decrease the user's weight below the minimum 0.2 lbs (0.1 kg); if the
/// user's weight would drop below the minimum, it becomes the minimum instead." A first use on a Pokémon
/// lighter than 100 kg still applies the weight reduction (the weight clamps to the minimum).
/// </summary>
[Test]
public async Task OnSecondaryEffect_FirstUseOnLightPokemon_StillAppliesWeightReduction()
{
// Arrange - 50 kg Pokémon, no previous Autotomize use
var (script, move, _, userVolatile, _) = CreateTestSetup(50f, true);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsTrue();
}
/// <summary>
/// Bulbapedia: "Weight loss from Autotomize stacks, so using it multiple times will continue to decrease
/// the user's weight accordingly until it reaches the minimum weight" and "if the user's weight would drop
/// below the minimum, it becomes the minimum instead." A Pokémon that still weighs more than the minimum
/// after two stacks must receive another stack (its weight drops to the 0.1 kg minimum).
/// </summary>
[Test]
public async Task OnSecondaryEffect_WeightWouldDropBelowMinimum_StillReducesToMinimum()
{
// Arrange - the user already has two stacks and currently weighs 50 kg
var existingEffect = new AutotomizeEffect();
existingEffect.Stack();
var (script, move, _, userVolatile, _) = CreateTestSetup(50f, true, existingEffect);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert - the weight should still be reduced (to the minimum)
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsTrue();
}
/// <summary>
/// Bulbapedia: "Autotomize cannot decrease the user's weight below the minimum 0.2 lbs (0.1 kg)".
/// A Pokémon already at the minimum weight cannot lose more weight, so no new stack is added and no
/// message is shown; the Speed boost itself is still applied.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AlreadyAtMinimumWeight_NoFurtherReductionButSpeedStillRaised()
{
// Arrange - the user already has a stack and is at the minimum weight
var (script, move, user, userVolatile, eventHook) = CreateTestSetup(0.1f, true, new AutotomizeEffect());
var eventFired = false;
eventHook.Handler += (_, _) => eventFired = true;
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
user.Received(1).ChangeStatBoost(Statistic.Speed, 2, true, false);
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsFalse();
await Assert.That(eventFired).IsFalse();
}
}

View File

@@ -0,0 +1,202 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
using TUnit.Assertions.AssertConditions.Throws;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="ChangeTargetSpecialDefense"/> script, which implements the secondary effect of Acid.
/// Behavior is verified against the Bulbapedia page for Acid.
/// Gen VII behavior: "Acid inflicts damage to all adjacent opponents" and "has a 10% chance of lowering the
/// target's Special Defense stat by one stage". The 10% proc chance and the targeting are handled by the
/// generic secondary effect data (chance: 10 in Moves.jsonc); this script is responsible for applying the
/// stat change once the effect procs.
/// </summary>
public class ChangeTargetSpecialDefenseTests
{
private static ChangeTargetSpecialDefense CreateInitializedScript(int amount = -1)
{
var script = new ChangeTargetSpecialDefense();
script.OnInitialize(new Dictionary<StringKey, object?> { { "amount", amount } });
return script;
}
/// <summary>
/// Helper to extract the arguments of the ChangeStatBoost call received by a substitute target.
/// </summary>
private static object?[]? GetStatBoostCallArgs(IPokemon target)
{
var call = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
return call?.GetArguments();
}
/// <summary>
/// Bulbapedia: "Acid now has a 10% chance of lowering the target's Special Defense stat by one stage instead."
/// (Generation IV onwards.) When the secondary effect procs, the target's Special Defense is lowered by one stage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetIsOpponent_LowersSpecialDefenseByOneStage()
{
// Arrange
var script = CreateInitializedScript();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var args = GetStatBoostCallArgs(target);
await Assert.That(args).IsNotNull();
await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.SpecialDefense);
await Assert.That((sbyte)args[1]!).IsEqualTo((sbyte)-1);
}
/// <summary>
/// Bulbapedia (Generation IV onwards): "lowering the target's Special Defense stat by one stage instead."
/// The word "instead" marks the change from the Gen I/II Defense drop — the script must not touch
/// the target's (physical) Defense stat.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetIsOpponent_DoesNotLowerDefense()
{
// Arrange
var script = CreateInitializedScript();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - exactly one stat change, and it is not Defense
var calls = target.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").ToList();
await Assert.That(calls.Count).IsEqualTo(1);
await Assert.That((Statistic)calls[0].GetArguments()[0]!).IsNotEqualTo(Statistic.Defense);
}
/// <summary>
/// Bulbapedia: "lowering the target's Special Defense stat".
/// The stat drop is inflicted on an opponent, so it must not be marked as self-inflicted
/// (self-inflicted drops bypass effects that prevent stat lowering by opponents, e.g. Clear Body/Mist).
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetIsOpponent_NotSelfInflicted()
{
// Arrange
var script = CreateInitializedScript();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var args = GetStatBoostCallArgs(target);
await Assert.That(args).IsNotNull();
await Assert.That((bool)args![2]!).IsFalse();
}
/// <summary>
/// Bulbapedia: "a 10% chance of lowering the target's Special Defense stat".
/// The drop is an ordinary stat change, not a forced one — it must respect prevention effects,
/// so the script may not pass force = true.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetIsOpponent_StatDropIsNotForced()
{
// Arrange
var script = CreateInitializedScript();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var args = GetStatBoostCallArgs(target);
await Assert.That(args).IsNotNull();
await Assert.That((bool)args![3]!).IsFalse();
}
/// <summary>
/// Technical test: the shared ChangeTargetStats base marks the stat change as self-inflicted
/// when the target of the secondary effect is the move's own user.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetIsUser_SelfInflicted()
{
// Arrange
var script = CreateInitializedScript();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
// Act - the user is hit by its own move (e.g. redirected)
script.OnSecondaryEffect(move, user, 0);
// Assert
var args = GetStatBoostCallArgs(user);
await Assert.That(args).IsNotNull();
await Assert.That((bool)args![2]!).IsTrue();
}
/// <summary>
/// Technical test: the 'amount' parameter from the move data (amount: -1 for Acid in Moves.jsonc)
/// is parsed by OnInitialize and passed through to the stat change unmodified.
/// </summary>
[Test, Arguments(-1), Arguments(-2)]
public async Task OnInitialize_AmountParameter_PassedToStatBoost(int amount)
{
// Arrange
var script = CreateInitializedScript(amount);
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var args = GetStatBoostCallArgs(target);
await Assert.That(args).IsNotNull();
await Assert.That((sbyte)args![1]!).IsEqualTo((sbyte)amount);
}
/// <summary>
/// Technical test: OnInitialize rejects a null parameter dictionary.
/// </summary>
[Test]
public async Task OnInitialize_NullParameters_ThrowsArgumentNullException()
{
// Arrange
var script = new ChangeTargetSpecialDefense();
// Act & Assert
await Assert.That(() => script.OnInitialize(null)).ThrowsExactly<ArgumentNullException>();
}
/// <summary>
/// Technical test: OnInitialize rejects parameters without the required 'amount' entry.
/// </summary>
[Test]
public async Task OnInitialize_MissingAmount_ThrowsArgumentException()
{
// Arrange
var script = new ChangeTargetSpecialDefense();
// Act & Assert
await Assert.That(() => script.OnInitialize(new Dictionary<StringKey, object?>()))
.ThrowsExactly<ArgumentException>();
}
}

View File

@@ -0,0 +1,243 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Abilities;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Drain"/> script, which implements Absorb (and the other HP-draining moves).
/// Behavior is verified against the Bulbapedia page for Absorb.
/// </summary>
public class DrainTests
{
/// <summary>
/// Creates a fully mocked test setup for Drain tests.
/// </summary>
private static (Drain drain, IExecutingMove move, IPokemon target, IPokemon user) CreateTestSetup(uint damage,
bool holdsBigRoot = false, Script[]? targetScripts = null)
{
var drain = new Drain();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
hitData.Damage.Returns(damage);
move.GetHitData(target, 0).Returns(hitData);
// RunScriptHook iterates the target's scripts; give the mock a real iterator so the
// hook pass runs (empty unless the test attaches scripts such as Liquid Ooze).
var containers = (targetScripts ?? []).Select(IEnumerable<ScriptContainer> (s) => new ScriptContainer(s))
.ToArray();
target.GetScripts().Returns(_ => new ScriptIterator(containers));
var user = Substitute.For<IPokemon>();
if (holdsBigRoot)
user.HasHeldItem("big_root").Returns(true);
move.User.Returns(user);
return (drain, move, target, user);
}
/// <summary>
/// Helper to extract the heal amount from the user's received Heal calls.
/// </summary>
private static uint? GetHealAmount(IPokemon user)
{
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Heal");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Helper to extract the damage amount from the user's received Damage calls.
/// </summary>
private static uint? GetDamageAmount(IPokemon user)
{
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Helper to extract the damage source from the user's received Damage calls.
/// </summary>
private static DamageSource? GetDamageSource(IPokemon user)
{
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (DamageSource)call.GetArguments()[1]! : null;
}
/// <summary>
/// Bulbapedia: "Absorb inflicts damage, and up to 50% of the damage dealt to the target is restored to the
/// user as HP".
/// </summary>
[Test]
public async Task OnSecondaryEffect_DamageDealt_UserHealsHalfOfDamage()
{
// Arrange
var (drain, move, target, user) = CreateTestSetup(100);
// Act
drain.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetHealAmount(user)).IsEqualTo(50u);
}
/// <summary>
/// Bulbapedia: "up to 50% of the damage dealt to the target is restored to the user as HP".
/// Tests various damage values to ensure proper integer truncation of the halved amount.
/// </summary>
[Test, Arguments(200u, 100u), Arguments(51u, 25u), Arguments(99u, 49u), Arguments(3u, 1u)]
public async Task OnSecondaryEffect_DamageDealt_HealCalculation(uint damage, uint expectedHeal)
{
// Arrange
var (drain, move, target, user) = CreateTestSetup(damage);
// Act
drain.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetHealAmount(user)).IsEqualTo(expectedHeal);
}
/// <summary>
/// Bulbapedia: "up to 50% of the damage dealt to the target is restored to the user as HP (but not less
/// than 1 HP)".
/// A move dealing 1 damage should still restore 1 HP.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DamageOfOne_HealsAtLeastOneHp()
{
// Arrange
var (drain, move, target, user) = CreateTestSetup(1);
// Act
drain.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetHealAmount(user)).IsEqualTo(1u);
}
/// <summary>
/// Bulbapedia: "If the user is holding a Big Root, the HP restored is increased by 30% (making the restored
/// HP 65% of the damage dealt)."
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHoldingBigRoot_HealIncreasedByThirtyPercent()
{
// Arrange
var (drain, move, target, user) = CreateTestSetup(100, true);
// Act
drain.OnSecondaryEffect(move, target, 0);
// Assert - 65% of 100 damage
await Assert.That(GetHealAmount(user)).IsEqualTo(65u);
}
/// <summary>
/// Bulbapedia: "When used on a Pokémon with the Liquid Ooze Ability, the user will lose the amount of HP it
/// would have gained instead."
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasLiquidOoze_UserLosesHpInsteadOfHealing()
{
// Arrange
var (drain, move, target, user) = CreateTestSetup(100, targetScripts: [new LiquidOoze()]);
// Act
drain.OnSecondaryEffect(move, target, 0);
// Assert - user takes the 50 HP it would have gained, and is not healed
await Assert.That(GetDamageAmount(user)).IsEqualTo(50u);
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Heal")).IsFalse();
}
/// <summary>
/// Bulbapedia: "the user will lose the amount of HP it would have gained instead."
/// The HP loss caused by Liquid Ooze is indirect damage, not move damage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasLiquidOoze_UsesMiscDamageSource()
{
// Arrange
var (drain, move, target, user) = CreateTestSetup(100, targetScripts: [new LiquidOoze()]);
// Act
drain.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageSource(user)).IsEqualTo(DamageSource.Misc);
}
/// <summary>
/// Bulbapedia: "the user will lose the amount of HP it would have gained instead."
/// With a Big Root held, the amount it would have gained is 65% of the damage, so that is what is lost.
/// </summary>
[Test]
public async Task OnSecondaryEffect_LiquidOozeAndBigRoot_UserLosesBoostedAmount()
{
// Arrange
var (drain, move, target, user) = CreateTestSetup(100, true, [new LiquidOoze()]);
// Act
drain.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageAmount(user)).IsEqualTo(65u);
}
/// <summary>
/// Technical test: without initialization parameters the drain modifier defaults to 50%, matching Absorb.
/// </summary>
[Test]
public async Task OnInitialize_NullParameters_DrainModifierDefaultsToHalf()
{
// Arrange
var drain = new Drain();
// Act
drain.OnInitialize(null);
// Assert
await Assert.That(drain.DrainModifier).IsEqualTo(0.5f);
}
/// <summary>
/// Technical test: a "drain_modifier" parameter overrides the default drain fraction.
/// </summary>
[Test]
public async Task OnInitialize_DrainModifierParameter_OverridesDefault()
{
// Arrange
var drain = new Drain();
var parameters = new Dictionary<StringKey, object?> { { "drain_modifier", 0.75f } };
// Act
drain.OnInitialize(parameters);
// Assert
await Assert.That(drain.DrainModifier).IsEqualTo(0.75f);
}
/// <summary>
/// Bulbapedia: "up to 50% of the damage dealt to the target is restored to the user as HP".
/// Integration check: initializing the script with Absorb's actual effect parameters from the Gen7 data
/// results in a 50% drain.
/// </summary>
[Test]
public async Task OnSecondaryEffect_InitializedWithAbsorbData_HealsHalfOfDamage()
{
// Arrange
var library = LibraryHelpers.LoadLibrary();
await Assert.That(library.StaticLibrary.Moves.TryGet("absorb", out var absorb)).IsTrue();
var (drain, move, target, user) = CreateTestSetup(100);
// Act
drain.OnInitialize(absorb!.SecondaryEffect!.Parameters);
drain.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetHealAmount(user)).IsEqualTo(50u);
}
}

View File

@@ -0,0 +1,9 @@
namespace PkmnLib.Plugin.Gen7.Tests;
/// <summary>
/// Marks a test as known to be failing, so that it will be skipped when running all tests, while still allowing us
/// to know that we should still fix it.
/// </summary>
public class TestFailingAttribute(string failureReason) : SkipAttribute("Test is currently failing: " + failureReason)
{
}

View File

@@ -14,15 +14,16 @@ public class Acupressure : Script, IScriptOnSecondaryEffect
/// <inheritdoc /> /// <inheritdoc />
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit) public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
{ {
var possibleStats = target.StatBoost.Where(x => x.statistic != Statistic.Hp && x.value < 6).ToArray();
// If the target has no stats to raise, the move fails // If the target has no stats to raise, the move fails
if (target.StatBoost.All(s => s.value == 6)) if (!possibleStats.Any())
{ {
move.GetHitData(target, hit).Fail(); move.GetHitData(target, hit).Fail();
return; return;
} }
// Choose a random stat to raise. 0 is HP, so we start at 1. var index = move.User.BattleData!.Battle.Random.GetInt(0, possibleStats.Length);
var stat = (Statistic)move.User.BattleData!.Battle.Random.GetInt(1, (int)Statistic.Speed + 1); var stat = possibleStats[index].statistic;
target.ChangeStatBoost(stat, 2, false, false); target.ChangeStatBoost(stat, 2, false, false);
} }
} }

View File

@@ -23,11 +23,10 @@ public class Autotomize : Script, IScriptOnSecondaryEffect
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit) public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
{ {
var user = move.User; var user = move.User;
if (!user.ChangeStatBoost(Statistic.Speed, 2, true, false))
return;
var existingEffect = user.Volatile.Get<AutotomizeEffect>(); if (!(user.WeightInKg > 0.1f))
var stacks = existingEffect?.Stacks ?? 0;
if (!user.ChangeStatBoost(Statistic.Speed, 2, true, false) || !(user.WeightInKg - 100f * stacks >= 0.1f))
return; return;
user.Volatile.StackOrAdd(ScriptUtils.ResolveName<AutotomizeEffect>(), () => new AutotomizeEffect()); user.Volatile.StackOrAdd(ScriptUtils.ResolveName<AutotomizeEffect>(), () => new AutotomizeEffect());

View File

@@ -27,6 +27,8 @@ public class Drain : Script, IScriptOnInitialize, IScriptOnSecondaryEffect
target.RunScriptHook<IScriptCustomTrigger>(x => x.CustomTrigger(CustomTriggers.ModifyDrain, args)); target.RunScriptHook<IScriptCustomTrigger>(x => x.CustomTrigger(CustomTriggers.ModifyDrain, args));
invert = args.Invert; invert = args.Invert;
healed = args.Healed; healed = args.Healed;
if (healed == 0)
healed = 1;
if (invert) if (invert)
{ {