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

This commit is contained in:
2026-07-05 18:26:55 +02:00
parent 1ab15110f5
commit 6a82c20cf2
20 changed files with 1682 additions and 12 deletions

View File

@@ -0,0 +1,212 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
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="Curse"/> move script and the <see cref="GhostCurseEffect"/> it applies.
/// Gen VII Bulbapedia behavior — non-Ghost user: "The user's Speed stat will drop by one stage and its
/// Attack stat and Defense stat will rise by one stage each." Ghost-type user: "The user will lose half of
/// its maximum HP (rounded down) and put a curse on the target", and "A cursed Pokémon will lose ¼ of its
/// maximum HP at the end of each turn."
/// </summary>
public class CurseTests
{
private static (Curse script, IExecutingMove move, IPokemon user, IPokemon target, IScriptSet targetVolatile)
CreateTestSetup(bool userIsGhost, uint userMaxHealth = 100, uint userCurrentHealth = 100)
{
var script = new Curse();
var library = LibraryHelpers.LoadLibrary();
library.StaticLibrary.Types.TryGetTypeIdentifier("ghost", out var ghostType);
library.StaticLibrary.Types.TryGetTypeIdentifier("normal", out var normalType);
var battle = Substitute.For<IBattle>();
battle.Library.Returns(library);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
user.Types.Returns(new[] { userIsGhost ? ghostType : normalType });
user.MaxHealth.Returns(userMaxHealth);
user.CurrentHealth.Returns(userCurrentHealth);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
// Use a real script set so the curse applied to the target can be inspected afterwards.
var targetVolatile = new ScriptSet(target);
target.Volatile.Returns(targetVolatile);
target.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
return (script, move, user, target, targetVolatile);
}
/// <summary>
/// Helper that checks whether a stat boost change was applied to the given Pokémon.
/// </summary>
private static bool ReceivedStatBoost(IPokemon pokemon, Statistic stat, sbyte amount) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost" &&
(Statistic)c.GetArguments()[0]! == stat &&
(sbyte)c.GetArguments()[1]! == amount);
/// <summary>
/// Helper to extract the damage amount from a substitute's received Damage calls.
/// </summary>
private static uint? GetDamageAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Bulbapedia (non-Ghost user): "The user's Speed stat will drop by one stage and its Attack stat and
/// Defense stat will rise by one stage each."
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonGhostUser_LowersSpeedAndRaisesAttackAndDefense()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedStatBoost(user, Statistic.Speed, -1)).IsTrue();
await Assert.That(ReceivedStatBoost(user, Statistic.Attack, 1)).IsTrue();
await Assert.That(ReceivedStatBoost(user, Statistic.Defense, 1)).IsTrue();
}
/// <summary>
/// Bulbapedia: only a Ghost-type user pays HP and curses the target — a non-Ghost user takes no damage
/// and does not curse the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonGhostUser_DoesNotDamageUserOrCurseTarget()
{
// Arrange
var (script, move, user, target, targetVolatile) = CreateTestSetup(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageAmount(user)).IsNull();
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<GhostCurseEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia (Ghost-type user): "The user will lose half of its maximum HP (rounded down)". At full
/// HP that is half of its current HP as well.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GhostUserAtFullHp_UserLosesHalfItsMaximumHp()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup(true, 100, 100);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(50u);
}
/// <summary>
/// Bulbapedia (Ghost-type user): the user pays HP to "put a curse on the target" — the
/// <see cref="GhostCurseEffect"/> is attached to the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GhostUser_CursesTarget()
{
// Arrange
var (script, move, _, target, targetVolatile) = CreateTestSetup(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<GhostCurseEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia (Ghost-type user): "The user will lose half of its maximum HP (rounded down)" — the cost
/// is based on maximum HP, not on the HP the user has left.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GhostUserAtHalfHp_UserStillLosesHalfItsMaximumHp()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup(true, 100, 50);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(50u);
}
/// <summary>
/// Bulbapedia (Ghost-type user): "If this causes the user's HP to drop to 0, the move will execute
/// fully but cause the user to faint." — the curse is still put on the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GhostUserFaintsFromHpCost_TargetIsStillCursed()
{
// Arrange
var (script, move, user, target, targetVolatile) = CreateTestSetup(true);
// The first read of CurrentHealth computes the HP cost; afterwards the user has fainted.
user.CurrentHealth.Returns(100u, 0u);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<GhostCurseEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "A cursed Pokémon will lose ¼ of its maximum HP at the end of each turn." At full HP
/// that is a quarter of its current HP as well.
/// </summary>
[Test]
public async Task GhostCurseEffect_OnEndTurnAtFullHp_CursedPokemonLosesQuarterOfMaximumHp()
{
// Arrange
var cursed = Substitute.For<IPokemon>();
cursed.MaxHealth.Returns(100u);
cursed.CurrentHealth.Returns(100u);
var effect = new GhostCurseEffect(cursed);
// Act
effect.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(cursed)!.Value).IsEqualTo(25u);
}
/// <summary>
/// Bulbapedia: "A cursed Pokémon will lose ¼ of its maximum HP at the end of each turn." — the drain
/// is based on maximum HP, not on the HP the Pokémon has left.
/// </summary>
[Test]
public async Task GhostCurseEffect_OnEndTurnAtLowHp_CursedPokemonStillLosesQuarterOfMaximumHp()
{
// Arrange
var cursed = Substitute.For<IPokemon>();
cursed.MaxHealth.Returns(100u);
cursed.CurrentHealth.Returns(40u);
var effect = new GhostCurseEffect(cursed);
// Act
effect.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(cursed)!.Value).IsEqualTo(25u);
}
}