[Test] Port over + augment remaining test matchers from pkty (#6159)

* Partially ported over pkty matchers (WIP)

* Cleaned up some more matchers

* Fiexd up matchers

* Fixed up remaining matchers

* Removed the word "matcher" from the pkty matcher functions

If we want them back we can always undo this commit and convert the other custom ones

* Added wip spite test

* Added `toHaveUsedPP` matcher

* Fixed up docs and tests

* Fixed spite test

* Ran biome

* Apply Biome

* Reverted biome breaking i18next

* Update src/typings/i18next.d.ts comment

* Fixed log message to not be overly verbose

* Added option to check for all PP used in pp matcher + cleaned up grudge tests

* Fixed up tests

* Fixed tests and such

* Fix various TSDocs + missing TSDoc imports
This commit is contained in:
Bertie690 2025-08-02 18:35:06 -04:00 committed by GitHub
parent acb1f4184b
commit 5ed9e152ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1395 additions and 132 deletions

View File

@ -19,7 +19,6 @@
// and having to verify whether each individual file is ignored
"includes": [
"**",
"!**/*.d.ts",
"!**/dist/**/*",
"!**/build/**/*",
"!**/coverage/**/*",
@ -180,7 +179,7 @@
// Overrides to prevent unused import removal inside `overrides.ts` and enums files (for TSDoc linkcodes),
// as well as in all TS files in `scripts/` (which are assumed to be boilerplate templates).
{
"includes": ["**/src/overrides.ts", "**/src/enums/**/*", "**/scripts/**/*.ts"],
"includes": ["**/src/overrides.ts", "**/src/enums/**/*", "**/scripts/**/*.ts", "**/*.d.ts"],
"linter": {
"rules": {
"correctness": {

10
global.d.ts vendored
View File

@ -1,7 +1,6 @@
import type { AnyFn } from "#types/type-helpers";
import type { SetupServerApi } from "msw/node";
export {};
declare global {
/**
* Only used in testing.
@ -11,4 +10,11 @@ declare global {
* To set up your own server in a test see `game-data.test.ts`
*/
var server: SetupServerApi;
// Overloads for `Function.apply` and `Function.call` to add type safety on matching argument types
interface Function {
apply<T extends AnyFn>(this: T, thisArg: ThisParameterType<T>, argArray: Parameters<T>): ReturnType<T>;
call<T extends AnyFn>(this: T, thisArg: ThisParameterType<T>, ...argArray: Parameters<T>): ReturnType<T>;
}
}

View File

@ -94,3 +94,12 @@ export type AbstractConstructor<T> = abstract new (...args: any[]) => T;
export type CoerceNullPropertiesToUndefined<T extends object> = {
[K in keyof T]: null extends T[K] ? Exclude<T[K], null> | undefined : T[K];
};
/**
* Type helper to mark all properties in `T` as optional, while still mandating that at least 1
* of its properties be present.
*
* Distinct from {@linkcode Partial} as this requires at least 1 property to _not_ be undefined.
* @typeParam T - The type to render partial
*/
export type AtLeastOne<T extends object> = Partial<T> & ObjectValues<{ [K in keyof T]: Pick<Required<T>, K> }>;

View File

@ -3560,6 +3560,7 @@ export class GrudgeTag extends SerializableBattlerTag {
* @param sourcePokemon - The source of the move that fainted the tag's bearer
* @returns `false` if Grudge activates its effect or lapses
*/
// TODO: Confirm whether this should interact with copying moves
override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType, sourcePokemon?: Pokemon): boolean {
if (lapseType === BattlerTagLapseType.CUSTOM && sourcePokemon) {
if (sourcePokemon.isActive() && pokemon.isOpponent(sourcePokemon)) {

View File

@ -3,6 +3,7 @@ import type { TOptions } from "i18next";
// Module declared to make referencing keys in the localization files type-safe.
declare module "i18next" {
interface TFunction {
// biome-ignore lint/style/useShorthandFunctionType: This needs to be an interface due to interface merging
(key: string | string[], options?: TOptions & Record<string, unknown>): string;
}
}

View File

@ -1,7 +1,7 @@
import "phaser";
declare module "phaser" {
namespace GameObjects {
namespace GameObjects {
interface GameObject {
width: number;
@ -16,45 +16,45 @@ declare module "phaser" {
y: number;
}
interface Container {
interface Container {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Sprite {
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Sprite {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Image {
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Image {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface NineSlice {
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface NineSlice {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Text {
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Text {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Rectangle {
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Rectangle {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
}
setPositionRelative(guideObject: any, x: number, y: number): this;
}
}
namespace Input {
namespace Input {
namespace Gamepad {
interface GamepadPlugin {
/**

18
src/vite.env.d.ts vendored
View File

@ -1,16 +1,16 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_PORT?: string;
readonly VITE_BYPASS_LOGIN?: string;
readonly VITE_BYPASS_TUTORIAL?: string;
readonly VITE_API_BASE_URL?: string;
readonly VITE_SERVER_URL?: string;
readonly VITE_DISCORD_CLIENT_ID?: string;
readonly VITE_GOOGLE_CLIENT_ID?: string;
readonly VITE_I18N_DEBUG?: string;
readonly VITE_PORT?: string;
readonly VITE_BYPASS_LOGIN?: string;
readonly VITE_BYPASS_TUTORIAL?: string;
readonly VITE_API_BASE_URL?: string;
readonly VITE_SERVER_URL?: string;
readonly VITE_DISCORD_CLIENT_ID?: string;
readonly VITE_GOOGLE_CLIENT_ID?: string;
readonly VITE_I18N_DEBUG?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv
readonly env: ImportMetaEnv;
}

View File

@ -1,26 +1,139 @@
import type { Pokemon } from "#field/pokemon";
import type { TerrainType } from "#app/data/terrain";
import type { AbilityId } from "#enums/ability-id";
import type { BattlerTagType } from "#enums/battler-tag-type";
import type { MoveId } from "#enums/move-id";
import type { PokemonType } from "#enums/pokemon-type";
import type { expect } from "vitest";
import type { BattleStat, EffectiveStat, Stat } from "#enums/stat";
import type { StatusEffect } from "#enums/status-effect";
import type { WeatherType } from "#enums/weather-type";
import type { Pokemon } from "#field/pokemon";
import type { ToHaveEffectiveStatMatcherOptions } from "#test/test-utils/matchers/to-have-effective-stat";
import type { expectedStatusType } from "#test/test-utils/matchers/to-have-status-effect";
import type { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types";
import type { TurnMove } from "#types/turn-move";
import type { AtLeastOne } from "#types/type-helpers";
import type { expect } from "vitest";
import type Overrides from "#app/overrides";
import type { PokemonMove } from "#moves/pokemon-move";
declare module "vitest" {
interface Assertion {
/**
* Matcher to check if an array contains EXACTLY the given items (in any order).
* Check whether an array contains EXACTLY the given items (in any order).
*
* Different from {@linkcode expect.arrayContaining} as the latter only requires the array contain
* _at least_ the listed items.
* Different from {@linkcode expect.arrayContaining} as the latter only checks for subset equality
* (as opposed to full equality).
*
* @param expected - The expected contents of the array, in any order.
* @param expected - The expected contents of the array, in any order
* @see {@linkcode expect.arrayContaining}
*/
toEqualArrayUnsorted<E>(expected: E[]): void;
/**
* Matcher to check if a {@linkcode Pokemon}'s current typing includes the given types.
* Check whether a {@linkcode Pokemon}'s current typing includes the given types.
*
* @param expected - The expected types (in any order).
* @param options - The options passed to the matcher.
* @param expected - The expected types (in any order)
* @param options - The options passed to the matcher
*/
toHaveTypes(expected: PokemonType[], options?: toHaveTypesOptions): void;
toHaveTypes(expected: [PokemonType, ...PokemonType[]], options?: toHaveTypesOptions): void;
/**
* Matcher to check the contents of a {@linkcode Pokemon}'s move history.
*
* @param expectedValue - The expected value; can be a {@linkcode MoveId} or a partially filled {@linkcode TurnMove}
* containing the desired properties to check
* @param index - The index of the move history entry to check, in order from most recent to least recent.
* Default `0` (last used move)
* @see {@linkcode Pokemon.getLastXMoves}
*/
toHaveUsedMove(expected: MoveId | AtLeastOne<TurnMove>, index?: number): void;
/**
* Check whether a {@linkcode Pokemon}'s effective stat is as expected
* (checked after all stat value modifications).
*
* @param stat - The {@linkcode EffectiveStat} to check
* @param expectedValue - The expected value of {@linkcode stat}
* @param options - (Optional) The {@linkcode ToHaveEffectiveStatMatcherOptions}
* @remarks
* If you want to check the stat **before** modifiers are applied, use {@linkcode Pokemon.getStat} instead.
*/
toHaveEffectiveStat(stat: EffectiveStat, expectedValue: number, options?: ToHaveEffectiveStatMatcherOptions): void;
/**
* Check whether a {@linkcode Pokemon} has taken a specific amount of damage.
* @param expectedDamageTaken - The expected amount of damage taken
* @param roundDown - Whether to round down {@linkcode expectedDamageTaken} with {@linkcode toDmgValue}; default `true`
*/
toHaveTakenDamage(expectedDamageTaken: number, roundDown?: boolean): void;
/**
* Check whether the current {@linkcode WeatherType} is as expected.
* @param expectedWeatherType - The expected {@linkcode WeatherType}
*/
toHaveWeather(expectedWeatherType: WeatherType): void;
/**
* Check whether the current {@linkcode TerrainType} is as expected.
* @param expectedTerrainType - The expected {@linkcode TerrainType}
*/
toHaveTerrain(expectedTerrainType: TerrainType): void;
/**
* Check whether a {@linkcode Pokemon} is at full HP.
*/
toHaveFullHp(): void;
/**
* Check whether a {@linkcode Pokemon} has a specific {@linkcode StatusEffect | non-volatile status effect}.
* @param expectedStatusEffect - The {@linkcode StatusEffect} the Pokemon is expected to have,
* or a partially filled {@linkcode Status} containing the desired properties
*/
toHaveStatusEffect(expectedStatusEffect: expectedStatusType): void;
/**
* Check whether a {@linkcode Pokemon} has a specific {@linkcode Stat} stage.
* @param stat - The {@linkcode BattleStat} to check
* @param expectedStage - The expected stat stage value of {@linkcode stat}
*/
toHaveStatStage(stat: BattleStat, expectedStage: number): void;
/**
* Check whether a {@linkcode Pokemon} has a specific {@linkcode BattlerTagType}.
* @param expectedBattlerTagType - The expected {@linkcode BattlerTagType}
*/
toHaveBattlerTag(expectedBattlerTagType: BattlerTagType): void;
/**
* Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}.
* @param expectedAbilityId - The expected {@linkcode AbilityId}
*/
toHaveAbilityApplied(expectedAbilityId: AbilityId): void;
/**
* Check whether a {@linkcode Pokemon} has a specific amount of {@linkcode Stat.HP | HP}.
* @param expectedHp - The expected amount of {@linkcode Stat.HP | HP} to have
*/
toHaveHp(expectedHp: number): void;
/**
* Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}).
* @remarks
* When checking whether an enemy wild Pokemon is fainted, one must reference it in a variable _before_ the fainting effect occurs
* as otherwise the Pokemon will be GC'ed and rendered `undefined`.
*/
toHaveFainted(): void;
/**
* Check whether a {@linkcode Pokemon} has consumed the given amount of PP for one of its moves.
* @param expectedValue - The {@linkcode MoveId} of the {@linkcode PokemonMove} that should have consumed PP
* @param ppUsed - The numerical amount of PP that should have been consumed,
* or `all` to indicate the move should be _out_ of PP
* @remarks
* If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.OPP_MOVESET_OVERRIDE},
* does not contain {@linkcode expectedMove}
* or contains the desired move more than once, this will fail the test.
*/
toHaveUsedPP(expectedMove: MoveId, ppUsed: number | "all"): void;
}
}
}

View File

@ -1,5 +1,18 @@
import { toEqualArrayUnsorted } from "#test/test-utils/matchers/to-equal-array-unsorted";
import { toHaveAbilityApplied } from "#test/test-utils/matchers/to-have-ability-applied";
import { toHaveBattlerTag } from "#test/test-utils/matchers/to-have-battler-tag";
import { toHaveEffectiveStat } from "#test/test-utils/matchers/to-have-effective-stat";
import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted";
import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp";
import { toHaveHp } from "#test/test-utils/matchers/to-have-hp";
import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage";
import { toHaveStatusEffect } from "#test/test-utils/matchers/to-have-status-effect";
import { toHaveTakenDamage } from "#test/test-utils/matchers/to-have-taken-damage";
import { toHaveTerrain } from "#test/test-utils/matchers/to-have-terrain";
import { toHaveTypes } from "#test/test-utils/matchers/to-have-types";
import { toHaveUsedMove } from "#test/test-utils/matchers/to-have-used-move";
import { toHaveUsedPP } from "#test/test-utils/matchers/to-have-used-pp";
import { toHaveWeather } from "#test/test-utils/matchers/to-have-weather";
import { expect } from "vitest";
/*
@ -10,4 +23,17 @@ import { expect } from "vitest";
expect.extend({
toEqualArrayUnsorted,
toHaveTypes,
toHaveUsedMove,
toHaveEffectiveStat,
toHaveTakenDamage,
toHaveWeather,
toHaveTerrain,
toHaveFullHp,
toHaveStatusEffect,
toHaveStatStage,
toHaveBattlerTag,
toHaveAbilityApplied,
toHaveHp,
toHaveFainted,
toHaveUsedPP,
});

View File

@ -2,6 +2,7 @@ import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -23,68 +24,64 @@ describe("Moves - Grudge", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.EMBER, MoveId.SPLASH])
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.SHEDINJA)
.enemyAbility(AbilityId.WONDER_GUARD)
.enemyMoveset([MoveId.GRUDGE, MoveId.SPLASH]);
.enemySpecies(SpeciesId.RATTATA)
.startingLevel(100)
.enemyAbility(AbilityId.NO_GUARD);
});
it("should reduce the PP of the Pokemon's move to 0 when the user has fainted", async () => {
it("should reduce the PP of an attack that faints the user to 0", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const playerPokemon = game.scene.getPlayerPokemon();
game.move.select(MoveId.EMBER);
await game.move.selectEnemyMove(MoveId.GRUDGE);
const feebas = game.field.getPlayerPokemon();
const ratatta = game.field.getEnemyPokemon();
game.move.use(MoveId.GUILLOTINE);
await game.move.forceEnemyMove(MoveId.GRUDGE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
await game.phaseInterceptor.to("FaintPhase");
const playerMove = playerPokemon?.getMoveset().find(m => m.moveId === MoveId.EMBER);
expect(playerMove?.getPpRatio()).toBe(0);
// Ratatta should have fainted and consumed all of Guillotine's PP
expect(ratatta).toHaveFainted();
expect(feebas).toHaveUsedPP(MoveId.GUILLOTINE, "all");
});
it("should remain in effect until the user's next move", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const playerPokemon = game.scene.getPlayerPokemon();
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.GRUDGE);
const feebas = game.field.getPlayerPokemon();
const ratatta = game.field.getEnemyPokemon();
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.GRUDGE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
game.move.select(MoveId.EMBER);
await game.move.selectEnemyMove(MoveId.SPLASH);
game.move.use(MoveId.GUILLOTINE);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
await game.toEndOfTurn();
const playerMove = playerPokemon?.getMoveset().find(m => m.moveId === MoveId.EMBER);
expect(playerMove?.getPpRatio()).toBe(0);
expect(ratatta).toHaveFainted();
expect(feebas).toHaveUsedPP(MoveId.GUILLOTINE, "all");
});
it("should not reduce the opponent's PP if the user dies to weather/indirect damage", async () => {
it("should not reduce PP if the user dies to weather/indirect damage", async () => {
// Opponent will be reduced to 1 HP by False Swipe, then faint to Sandstorm
game.override
.moveset([MoveId.FALSE_SWIPE])
.startingLevel(100)
.ability(AbilityId.SAND_STREAM)
.enemySpecies(SpeciesId.RATTATA);
await game.classicMode.startBattle([SpeciesId.GEODUDE]);
game.override.weather(WeatherType.SANDSTORM);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemyPokemon = game.scene.getEnemyPokemon();
const playerPokemon = game.scene.getPlayerPokemon();
const feebas = game.field.getPlayerPokemon();
const ratatta = game.field.getEnemyPokemon();
game.move.select(MoveId.FALSE_SWIPE);
await game.move.selectEnemyMove(MoveId.GRUDGE);
game.move.use(MoveId.FALSE_SWIPE);
await game.move.forceEnemyMove(MoveId.GRUDGE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
await game.toEndOfTurn();
expect(enemyPokemon?.isFainted()).toBe(true);
const playerMove = playerPokemon?.getMoveset().find(m => m.moveId === MoveId.FALSE_SWIPE);
expect(playerMove?.getPpRatio()).toBeGreaterThan(0);
expect(ratatta).toHaveFainted();
expect(feebas).toHaveUsedPP(MoveId.FALSE_SWIPE, 1);
});
});

126
test/moves/spite.test.ts Normal file
View File

@ -0,0 +1,126 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { MoveUseMode } from "#enums/move-use-mode";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Spite", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.startingLevel(100)
.enemyLevel(100);
});
it("should reduce the PP of the target's last used move by 4", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const karp = game.field.getEnemyPokemon();
game.move.changeMoveset(karp, [MoveId.SPLASH, MoveId.TACKLE]);
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
expect(karp).toHaveUsedPP(MoveId.TACKLE, 1);
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(karp).toHaveUsedPP(MoveId.TACKLE, 4 + 1);
});
it("should fail if the target has not used a move", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const karp = game.field.getEnemyPokemon();
game.move.changeMoveset(karp, [MoveId.SPLASH, MoveId.TACKLE]);
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
const feebas = game.field.getPlayerPokemon();
expect(feebas).toHaveUsedMove({ move: MoveId.SPITE, result: MoveResult.FAIL });
});
it("should fail if the target's last used move is out of PP", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const karp = game.field.getEnemyPokemon();
game.move.changeMoveset(karp, [MoveId.TACKLE]);
karp.moveset[0].ppUsed = 0;
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
const feebas = game.field.getPlayerPokemon();
expect(feebas).toHaveUsedMove({ move: MoveId.SPITE, result: MoveResult.FAIL });
});
it("should fail if the target's last used move is not in their moveset", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const karp = game.field.getEnemyPokemon();
game.move.changeMoveset(karp, [MoveId.TACKLE]);
// Fake magikarp having used Splash the turn prior
karp.pushMoveHistory({ move: MoveId.SPLASH, targets: [BattlerIndex.ENEMY], useMode: MoveUseMode.NORMAL });
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
const feebas = game.field.getPlayerPokemon();
expect(feebas).toHaveUsedMove({ move: MoveId.SPITE, result: MoveResult.FAIL });
});
it("should ignore virtual and Dancer-induced moves", async () => {
game.override.battleStyle("double").enemyAbility(AbilityId.DANCER);
game.move.forceMetronomeMove(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const [karp1, karp2] = game.scene.getEnemyField();
game.move.changeMoveset(karp1, [MoveId.SPLASH, MoveId.METRONOME, MoveId.SWORDS_DANCE]);
game.move.changeMoveset(karp2, [MoveId.SWORDS_DANCE, MoveId.TACKLE]);
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.METRONOME);
await game.move.selectEnemyMove(MoveId.SWORDS_DANCE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Spite ignored virtual splash and swords dance, instead only docking from metronome
expect(karp1).toHaveUsedPP(MoveId.SPLASH, 0);
expect(karp1).toHaveUsedPP(MoveId.SWORDS_DANCE, 0);
expect(karp1).toHaveUsedPP(MoveId.METRONOME, 5);
});
});

View File

@ -1,43 +1,47 @@
import { getOnelineDiffStr } from "#test/test-utils/string-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check if an array contains exactly the given items, disregarding order.
* @param received - The object to check. Should be an array of elements.
* @returns The result of the matching
* Matcher that checks if an array contains exactly the given items, disregarding order.
* @param received - The received value. Should be an array of elements
* @param expected - The array to check equality with
* @returns Whether the matcher passed
*/
export function toEqualArrayUnsorted(this: MatcherState, received: unknown, expected: unknown): SyncExpectationResult {
export function toEqualArrayUnsorted(
this: MatcherState,
received: unknown,
expected: unknown[],
): SyncExpectationResult {
if (!Array.isArray(received)) {
return {
pass: this.isNot,
pass: false,
message: () => `Expected an array, but got ${this.utils.stringify(received)}!`,
};
}
if (!Array.isArray(expected)) {
return {
pass: this.isNot,
message: () => `Expected to recieve an array, but got ${this.utils.stringify(expected)}!`,
};
}
if (received.length !== expected.length) {
return {
pass: this.isNot,
message: () => `Expected to recieve array of length ${received.length}, but got ${expected.length}!`,
pass: false,
message: () => `Expected to receive array of length ${received.length}, but got ${expected.length} instead!`,
actual: received,
expected,
};
}
const gotSorted = received.slice().sort();
const wantSorted = expected.slice().sort();
const pass = this.equals(gotSorted, wantSorted, [...this.customTesters, this.utils.iterableEquality]);
const actualSorted = received.slice().sort();
const expectedSorted = expected.slice().sort();
const pass = this.equals(actualSorted, expectedSorted, [...this.customTesters, this.utils.iterableEquality]);
const actualStr = getOnelineDiffStr.call(this, actualSorted);
const expectedStr = getOnelineDiffStr.call(this, expectedSorted);
return {
pass: this.isNot !== pass,
pass,
message: () =>
`Expected ${this.utils.stringify(received)} to exactly equal ${this.utils.stringify(expected)} without order!`,
actual: gotSorted,
expected: wantSorted,
pass
? `Expected ${actualStr} to NOT exactly equal ${expectedStr} without order, but it did!`
: `Expected ${actualStr} to exactly equal ${expectedStr} without order, but it didn't!`,
expected: expectedSorted,
actual: actualSorted,
};
}

View File

@ -0,0 +1,43 @@
/* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { Pokemon } from "#field/pokemon";
/* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import { AbilityId } from "#enums/ability-id";
import { getEnumStr } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}.
* @param received - The object to check. Should be a {@linkcode Pokemon}
* @param expectedAbility - The {@linkcode AbilityId} to check for
* @returns Whether the matcher passed
*/
export function toHaveAbilityApplied(
this: MatcherState,
received: unknown,
expectedAbilityId: AbilityId,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to recieve a Pokemon, but got ${receivedStr(received)}!`,
};
}
const pass = received.waveData.abilitiesApplied.has(expectedAbilityId);
const pkmName = getPokemonNameWithAffix(received);
const expectedAbilityStr = getEnumStr(AbilityId, expectedAbilityId);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have applied ${expectedAbilityStr}, but it did!`
: `Expected ${pkmName} to have applied ${expectedAbilityStr}, but it didn't!`,
expected: expectedAbilityId,
actual: received.waveData.abilitiesApplied,
};
}

View File

@ -0,0 +1,43 @@
/* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { Pokemon } from "#field/pokemon";
/* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import { BattlerTagType } from "#enums/battler-tag-type";
import { getEnumStr } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a {@linkcode Pokemon} has a specific {@linkcode BattlerTagType}.
* @param received - The object to check. Should be a {@linkcode Pokemon}
* @param expectedBattlerTagType - The {@linkcode BattlerTagType} to check for
* @returns Whether the matcher passed
*/
export function toHaveBattlerTag(
this: MatcherState,
received: unknown,
expectedBattlerTagType: BattlerTagType,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const pass = !!received.getTag(expectedBattlerTagType);
const pkmName = getPokemonNameWithAffix(received);
// "BattlerTagType.SEEDED (=1)"
const expectedTagStr = getEnumStr(BattlerTagType, expectedBattlerTagType, { prefix: "BattlerTagType." });
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have ${expectedTagStr}, but it did!`
: `Expected ${pkmName} to have ${expectedTagStr}, but it didn't!`,
expected: expectedBattlerTagType,
actual: received.summonData.tags.map(t => t.tagType),
};
}

View File

@ -0,0 +1,66 @@
import { getPokemonNameWithAffix } from "#app/messages";
import type { EffectiveStat } from "#enums/stat";
import type { Pokemon } from "#field/pokemon";
import type { Move } from "#moves/move";
import { getStatName } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export interface ToHaveEffectiveStatMatcherOptions {
/**
* The target {@linkcode Pokemon}
* @see {@linkcode Pokemon.getEffectiveStat}
*/
enemy?: Pokemon;
/**
* The {@linkcode Move} being used
* @see {@linkcode Pokemon.getEffectiveStat}
*/
move?: Move;
/**
* Whether a critical hit occurred or not
* @see {@linkcode Pokemon.getEffectiveStat}
* @defaultValue `false`
*/
isCritical?: boolean;
}
/**
* Matcher that checks if a {@linkcode Pokemon}'s effective stat equals a certain value.
* @param received - The object to check. Should be a {@linkcode Pokemon}
* @param stat - The {@linkcode EffectiveStat} to check
* @param expectedValue - The expected value of the {@linkcode stat}
* @param options - The {@linkcode ToHaveEffectiveStatMatcherOptions}
* @returns Whether the matcher passed
*/
export function toHaveEffectiveStat(
this: MatcherState,
received: unknown,
stat: EffectiveStat,
expectedValue: number,
{ enemy, move, isCritical = false }: ToHaveEffectiveStatMatcherOptions = {},
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
// TODO: Change once getEffectiveStat is refactored to take an object literal
const actualValue = received.getEffectiveStat(stat, enemy, move, undefined, undefined, undefined, isCritical);
const pass = actualValue === expectedValue;
const pkmName = getPokemonNameWithAffix(received);
const statName = getStatName(stat);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have ${expectedValue} ${statName}, but it did!`
: `Expected ${pkmName} to have ${expectedValue} ${statName}, but got ${actualValue} instead!`,
expected: expectedValue,
actual: actualValue,
};
}

View File

@ -0,0 +1,35 @@
import { getPokemonNameWithAffix } from "#app/messages";
// biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { Pokemon } from "#field/pokemon";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a {@linkcode Pokemon} has fainted.
* @param received - The object to check. Should be a {@linkcode Pokemon}
* @returns Whether the matcher passed
*/
export function toHaveFainted(this: MatcherState, received: unknown): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const pass = received.isFainted();
const hp = received.hp;
const maxHp = received.getMaxHp();
const pkmName = getPokemonNameWithAffix(received);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have fainted, but it did!`
: `Expected ${pkmName} to have fainted, but it didn't! (${hp}/${maxHp} HP)`,
expected: 0,
actual: hp,
};
}

View File

@ -0,0 +1,35 @@
import { getPokemonNameWithAffix } from "#app/messages";
// biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { Pokemon } from "#field/pokemon";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a {@linkcode Pokemon} is at full hp.
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @returns Whether the matcher passed
*/
export function toHaveFullHp(this: MatcherState, received: unknown): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const pass = received.isFullHp();
const hp = received.hp;
const maxHp = received.getMaxHp();
const pkmName = getPokemonNameWithAffix(received);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have full hp, but it did!`
: `Expected ${pkmName} to have full hp, but it didn't! (${hp}/${maxHp} HP)`,
expected: maxHp,
actual: hp,
};
}

View File

@ -0,0 +1,35 @@
import { getPokemonNameWithAffix } from "#app/messages";
// biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { Pokemon } from "#field/pokemon";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a Pokemon has a specific amount of HP.
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @param expectedHp - The expected amount of HP the {@linkcode Pokemon} has
* @returns Whether the matcher passed
*/
export function toHaveHp(this: MatcherState, received: unknown, expectedHp: number): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const actualHp = received.hp;
const pass = actualHp === expectedHp;
const pkmName = getPokemonNameWithAffix(received);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have ${expectedHp} HP, but it did!`
: `Expected ${pkmName} to have ${expectedHp} HP, but got ${actualHp} HP instead!`,
expected: expectedHp,
actual: actualHp,
};
}

View File

@ -0,0 +1,53 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { Pokemon } from "#field/pokemon";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import type { BattleStat } from "#enums/stat";
import { getStatName } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a Pokemon has a specific {@linkcode BattleStat | Stat} stage.
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @param stat - The {@linkcode BattleStat | Stat} to check
* @param expectedStage - The expected numerical value of {@linkcode stat}; should be within the range `[-6, 6]`
* @returns Whether the matcher passed
*/
export function toHaveStatStage(
this: MatcherState,
received: unknown,
stat: BattleStat,
expectedStage: number,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
if (expectedStage < -6 || expectedStage > 6) {
return {
pass: false,
message: () => `Expected ${expectedStage} to be within the range [-6, 6]!`,
};
}
const actualStage = received.getStatStage(stat);
const pass = actualStage === expectedStage;
const pkmName = getPokemonNameWithAffix(received);
const statName = getStatName(stat);
return {
pass,
message: () =>
pass
? `Expected ${pkmName}'s ${statName} stat stage to NOT be ${expectedStage}, but it was!`
: `Expected ${pkmName}'s ${statName} stat stage to be ${expectedStage}, but got ${actualStage} instead!`,
expected: expectedStage,
actual: actualStage,
};
}

View File

@ -0,0 +1,83 @@
/* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { Status } from "#data/status-effect";
import type { Pokemon } from "#field/pokemon";
/* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import { StatusEffect } from "#enums/status-effect";
import { getEnumStr, getOnelineDiffStr } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export type expectedStatusType =
| StatusEffect
| { effect: StatusEffect.TOXIC; toxicTurnCount: number }
| { effect: StatusEffect.SLEEP; sleepTurnsRemaining: number };
/**
* Matcher that checks if a Pokemon's {@linkcode StatusEffect} is as expected
* @param received - The actual value received. Should be a {@linkcode Pokemon}
* @param expectedStatus - The {@linkcode StatusEffect} the Pokemon is expected to have,
* or a partially filled {@linkcode Status} containing the desired properties
* @returns Whether the matcher passed
*/
export function toHaveStatusEffect(
this: MatcherState,
received: unknown,
expectedStatus: expectedStatusType,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const pkmName = getPokemonNameWithAffix(received);
const actualEffect = received.status?.effect ?? StatusEffect.NONE;
// Check exclusively effect equality first, coercing non-matching status effects to numbers.
if (actualEffect !== (expectedStatus as Exclude<typeof expectedStatus, StatusEffect>)?.effect) {
// This is actually 100% safe as `expectedStatus?.effect` will evaluate to `undefined` if a StatusEffect was passed,
// which will never match actualEffect by definition
expectedStatus = (expectedStatus as Exclude<typeof expectedStatus, StatusEffect>).effect;
}
if (typeof expectedStatus === "number") {
const pass = this.equals(actualEffect, expectedStatus, [...this.customTesters, this.utils.iterableEquality]);
const actualStr = getEnumStr(StatusEffect, actualEffect, { prefix: "StatusEffect." });
const expectedStr = getEnumStr(StatusEffect, expectedStatus, { prefix: "StatusEffect." });
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have ${expectedStr}, but it did!`
: `Expected ${pkmName} to have status effect ${expectedStr}, but got ${actualStr} instead!`,
expected: expectedStatus,
actual: actualEffect,
};
}
// Check for equality of all fields (for toxic turn count/etc)
const actualStatus = received.status;
const pass = this.equals(received, expectedStatus, [
...this.customTesters,
this.utils.subsetEquality,
this.utils.iterableEquality,
]);
const expectedStr = getOnelineDiffStr.call(this, expectedStatus);
const actualStr = getOnelineDiffStr.call(this, actualStatus);
return {
pass,
message: () =>
pass
? `Expected ${pkmName}'s status to NOT match ${expectedStr}, but it did!`
: `Expected ${pkmName}'s status to match ${expectedStr}, but got ${actualStr} instead!`,
expected: expectedStatus,
actual: actualStatus,
};
}

View File

@ -0,0 +1,46 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { Pokemon } from "#field/pokemon";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import { toDmgValue } from "#utils/common";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a Pokemon has taken a specific amount of damage.
* Unless specified, will run the expected damage value through {@linkcode toDmgValue}
* to round it down and make it a minimum of 1.
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @param expectedDamageTaken - The expected amount of damage the {@linkcode Pokemon} has taken
* @param roundDown - Whether to round down {@linkcode expectedDamageTaken} with {@linkcode toDmgValue}; default `true`
* @returns Whether the matcher passed
*/
export function toHaveTakenDamage(
this: MatcherState,
received: unknown,
expectedDamageTaken: number,
roundDown = true,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const expectedDmgValue = roundDown ? toDmgValue(expectedDamageTaken) : expectedDamageTaken;
const actualDmgValue = received.getInverseHp();
const pass = actualDmgValue === expectedDmgValue;
const pkmName = getPokemonNameWithAffix(received);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have taken ${expectedDmgValue} damage, but it did!`
: `Expected ${pkmName} to have taken ${expectedDmgValue} damage, but got ${actualDmgValue} instead!`,
expected: expectedDmgValue,
actual: actualDmgValue,
};
}

View File

@ -0,0 +1,62 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { GameManager } from "#test/test-utils/game-manager";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { TerrainType } from "#app/data/terrain";
import { getEnumStr } from "#test/test-utils/string-utils";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if the {@linkcode TerrainType} is as expected
* @param received - The object to check. Should be an instance of {@linkcode GameManager}.
* @param expectedTerrainType - The expected {@linkcode TerrainType}, or {@linkcode TerrainType.NONE} if no terrain should be active
* @returns Whether the matcher passed
*/
export function toHaveTerrain(
this: MatcherState,
received: unknown,
expectedTerrainType: TerrainType,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {
return {
pass: false,
message: () => `Expected GameManager, but got ${receivedStr(received)}!`,
};
}
if (!received.scene?.arena) {
return {
pass: false,
message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`,
};
}
const actual = received.scene.arena.getTerrainType();
const pass = actual === expectedTerrainType;
const actualStr = toTerrainStr(actual);
const expectedStr = toTerrainStr(expectedTerrainType);
return {
pass,
message: () =>
pass
? `Expected Arena to NOT have ${expectedStr} active, but it did!`
: `Expected Arena to have ${expectedStr} active, but got ${actualStr} instead!`,
expected: expectedTerrainType,
actual,
};
}
/**
* Get a human readable string of the current {@linkcode TerrainType}.
* @param terrainType - The {@linkcode TerrainType} to transform
* @returns A human readable string
*/
function toTerrainStr(terrainType: TerrainType) {
if (terrainType === TerrainType.NONE) {
return "no terrain";
}
// "Electric Terrain (=2)"
return getEnumStr(TerrainType, terrainType, { casing: "Title", suffix: " Terrain" });
}

View File

@ -1,6 +1,9 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { PokemonType } from "#enums/pokemon-type";
import { Pokemon } from "#field/pokemon";
import type { Pokemon } from "#field/pokemon";
import { stringifyEnumArray } from "#test/test-utils/string-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
import { isPokemonInstance, receivedStr } from "../test-utils";
export interface toHaveTypesOptions {
/**
@ -15,7 +18,7 @@ export interface toHaveTypesOptions {
}
/**
* Matcher to check if an array contains exactly the given items, disregarding order.
* Matcher that checks if an array contains exactly the given items, disregarding order.
* @param received - The object to check. Should be an array of one or more {@linkcode PokemonType}s.
* @param options - The {@linkcode toHaveTypesOptions | options} for this matcher
* @returns The result of the matching
@ -23,42 +26,36 @@ export interface toHaveTypesOptions {
export function toHaveTypes(
this: MatcherState,
received: unknown,
expected: unknown,
expected: [PokemonType, ...PokemonType[]],
options: toHaveTypesOptions = {},
): SyncExpectationResult {
if (!(received instanceof Pokemon)) {
if (!isPokemonInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected a Pokemon, but got ${this.utils.stringify(received)}!`,
pass: false,
message: () => `Expected to recieve a Pokémon, but got ${receivedStr(received)}!`,
};
}
if (!Array.isArray(expected) || expected.length === 0) {
return {
pass: this.isNot,
message: () => `Expected to recieve an array with length >=1, but got ${this.utils.stringify(expected)}!`,
};
}
const actualTypes = received.getTypes(...(options.args ?? [])).sort();
const expectedTypes = expected.slice().sort();
if (!expected.every((t): t is PokemonType => t in PokemonType)) {
return {
pass: this.isNot,
message: () => `Expected to recieve array of PokemonTypes but got ${this.utils.stringify(expected)}!`,
};
}
// Exact matches do not care about subset equality
const matchers = options.exact
? [...this.customTesters, this.utils.iterableEquality]
: [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality];
const pass = this.equals(actualTypes, expectedTypes, matchers);
const gotSorted = pkmnTypeToStr(received.getTypes(...(options.args ?? [])));
const wantSorted = pkmnTypeToStr(expected.slice());
const pass = this.equals(gotSorted, wantSorted, [...this.customTesters, this.utils.iterableEquality]);
const actualStr = stringifyEnumArray(PokemonType, actualTypes);
const expectedStr = stringifyEnumArray(PokemonType, expectedTypes);
const pkmName = getPokemonNameWithAffix(received);
return {
pass: this.isNot !== pass,
message: () => `Expected ${received.name} to have types ${this.utils.stringify(wantSorted)}, but got ${gotSorted}!`,
actual: gotSorted,
expected: wantSorted,
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have types ${expectedStr}, but it did!`
: `Expected ${pkmName} to have types ${expectedStr}, but got ${actualStr} instead!`,
expected: expectedTypes,
actual: actualTypes,
};
}
function pkmnTypeToStr(p: PokemonType[]): string[] {
return p.sort().map(type => PokemonType[type]);
}

View File

@ -0,0 +1,70 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { Pokemon } from "#field/pokemon";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import type { MoveId } from "#enums/move-id";
import { getOnelineDiffStr, getOrdinal } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { TurnMove } from "#types/turn-move";
import type { AtLeastOne } from "#types/type-helpers";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check the contents of a {@linkcode Pokemon}'s move history.
* @param received - The actual value received. Should be a {@linkcode Pokemon}
* @param expectedValue - The {@linkcode MoveId} the Pokemon is expected to have used,
* or a partially filled {@linkcode TurnMove} containing the desired properties to check
* @param index - The index of the move history entry to check, in order from most recent to least recent.
* Default `0` (last used move)
* @returns Whether the matcher passed
*/
export function toHaveUsedMove(
this: MatcherState,
received: unknown,
expectedResult: MoveId | AtLeastOne<TurnMove>,
index = 0,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const move: TurnMove | undefined = received.getLastXMoves(-1)[index];
const pkmName = getPokemonNameWithAffix(received);
if (move === undefined) {
return {
pass: false,
message: () => `Expected ${pkmName} to have used ${index + 1} moves, but it didn't!`,
actual: received.getLastXMoves(-1),
};
}
// Coerce to a `TurnMove`
if (typeof expectedResult === "number") {
expectedResult = { move: expectedResult };
}
const moveIndexStr = index === 0 ? "last move" : `${getOrdinal(index)} most recent move`;
const pass = this.equals(move, expectedResult, [
...this.customTesters,
this.utils.subsetEquality,
this.utils.iterableEquality,
]);
const expectedStr = getOnelineDiffStr.call(this, expectedResult);
return {
pass,
message: () =>
pass
? `Expected ${pkmName}'s ${moveIndexStr} to NOT match ${expectedStr}, but it did!`
: // Replace newlines with spaces to preserve one-line ness
`Expected ${pkmName}'s ${moveIndexStr} to match ${expectedStr}, but it didn't!`,
expected: expectedResult,
actual: move,
};
}

View File

@ -0,0 +1,77 @@
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports
import type { Pokemon } from "#field/pokemon";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports
import { getPokemonNameWithAffix } from "#app/messages";
import Overrides from "#app/overrides";
import { MoveId } from "#enums/move-id";
import { getEnumStr } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import { coerceArray } from "#utils/common";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check the amount of PP consumed by a {@linkcode Pokemon}.
* @param received - The actual value received. Should be a {@linkcode Pokemon}
* @param expectedValue - The {@linkcode MoveId} that should have consumed PP
* @param ppUsed - The numerical amount of PP that should have been consumed,
* or `all` to indicate the move should be _out_ of PP
* @returns Whether the matcher passed
* @remarks
* If the same move appears in the Pokemon's moveset multiple times, this will fail the test!
*/
export function toHaveUsedPP(
this: MatcherState,
received: unknown,
expectedMove: MoveId,
ppUsed: number | "all",
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.OPP_MOVESET_OVERRIDE;
if (coerceArray(override).length > 0) {
return {
pass: false,
message: () =>
`Cannot test for PP consumption with ${received.isPlayer() ? "player" : "enemy"} moveset overrides active!`,
};
}
const pkmName = getPokemonNameWithAffix(received);
const moveStr = getEnumStr(MoveId, expectedMove);
const movesetMoves = received.getMoveset().filter(pm => pm.moveId === expectedMove);
if (movesetMoves.length !== 1) {
return {
pass: false,
message: () =>
`Expected MoveId.${moveStr} to appear in ${pkmName}'s moveset exactly once, but got ${movesetMoves.length} times!`,
expected: expectedMove,
actual: received.getMoveset(),
};
}
const move = movesetMoves[0]; // will be the only move in the array
let ppStr: string = ppUsed.toString();
if (typeof ppUsed === "string") {
ppStr = "all its";
ppUsed = move.getMovePp();
}
const pass = move.ppUsed === ppUsed;
return {
pass,
message: () =>
pass
? `Expected ${pkmName}'s ${moveStr} to NOT have used ${ppStr} PP, but it did!`
: `Expected ${pkmName}'s ${moveStr} to have used ${ppStr} PP, but got ${move.ppUsed} instead!`,
expected: ppUsed,
actual: move.ppUsed,
};
}

View File

@ -0,0 +1,62 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { GameManager } from "#test/test-utils/game-manager";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { WeatherType } from "#enums/weather-type";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import { toTitleCase } from "#utils/strings";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if the {@linkcode WeatherType} is as expected
* @param received - The object to check. Expects an instance of {@linkcode GameManager}.
* @param expectedWeatherType - The expected {@linkcode WeatherType}
* @returns Whether the matcher passed
*/
export function toHaveWeather(
this: MatcherState,
received: unknown,
expectedWeatherType: WeatherType,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {
return {
pass: false,
message: () => `Expected GameManager, but got ${receivedStr(received)}!`,
};
}
if (!received.scene?.arena) {
return {
pass: false,
message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`,
};
}
const actual = received.scene.arena.getWeatherType();
const pass = actual === expectedWeatherType;
const actualStr = toWeatherStr(actual);
const expectedStr = toWeatherStr(expectedWeatherType);
return {
pass,
message: () =>
pass
? `Expected Arena to NOT have ${expectedStr} weather active, but it did!`
: `Expected Arena to have ${expectedStr} weather active, but got ${actualStr} instead!`,
expected: expectedWeatherType,
actual,
};
}
/**
* Get a human readable representation of the current {@linkcode WeatherType}.
* @param weatherType - The {@linkcode WeatherType} to transform
* @returns A human readable string
*/
function toWeatherStr(weatherType: WeatherType) {
if (weatherType === WeatherType.NONE) {
return "no weather";
}
return toTitleCase(WeatherType[weatherType]);
}

View File

@ -0,0 +1,183 @@
import { getStatKey, type Stat } from "#enums/stat";
import type { EnumOrObject, NormalEnum, TSNumericEnum } from "#types/enum-types";
import type { ObjectValues } from "#types/type-helpers";
import { enumValueToKey } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import type { MatcherState } from "@vitest/expect";
import i18next from "i18next";
type Casing = "Preserve" | "Title";
interface getEnumStrOptions {
/**
* A string denoting the casing method to use.
* @defaultValue "Preserve"
*/
casing?: Casing;
/**
* If present, will be prepended to the beginning of the enum string.
*/
prefix?: string;
/**
* If present, will be added to the end of the enum string.
*/
suffix?: string;
}
/**
* Return the name of an enum member or const object value, alongside its corresponding value.
* @param obj - The {@linkcode EnumOrObject} to source reverse mappings from
* @param enums - One of {@linkcode obj}'s values
* @param casing - A string denoting the casing method to use; default `Preserve`
* @param prefix - An optional string to be prepended to the enum's string representation
* @param suffix - An optional string to be appended to the enum's string representation
* @returns The stringified representation of `val` as dictated by the options.
* @example
* ```ts
* enum fakeEnum {
* ONE: 1,
* TWO: 2,
* THREE: 3,
* }
* getEnumStr(fakeEnum, fakeEnum.ONE); // Output: "ONE (=1)"
* getEnumStr(fakeEnum, fakeEnum.TWO, {casing: "Title", prefix: "fakeEnum.", suffix: "!!!"}); // Output: "fakeEnum.TWO!!! (=2)"
* ```
*/
export function getEnumStr<E extends EnumOrObject>(
obj: E,
val: ObjectValues<E>,
{ casing = "Preserve", prefix = "", suffix = "" }: getEnumStrOptions = {},
): string {
let casingFunc: ((s: string) => string) | undefined;
switch (casing) {
case "Preserve":
break;
case "Title":
casingFunc = toTitleCase;
break;
}
let stringPart =
obj[val] !== undefined
? // TS reverse mapped enum
(obj[val] as string)
: // Normal enum/`const object`
(enumValueToKey(obj as NormalEnum<E>, val) as string);
if (casingFunc) {
stringPart = casingFunc(stringPart);
}
return `${prefix}${stringPart}${suffix} (=${val})`;
}
/**
* Convert an array of enums or `const object`s into a readable string version.
* @param obj - The {@linkcode EnumOrObject} to source reverse mappings from
* @param enums - An array of {@linkcode obj}'s values
* @returns The stringified representation of `enums`.
* @example
* ```ts
* enum fakeEnum {
* ONE: 1,
* TWO: 2,
* THREE: 3,
* }
* console.log(stringifyEnumArray(fakeEnum, [fakeEnum.ONE, fakeEnum.TWO, fakeEnum.THREE])); // Output: "[ONE, TWO, THREE] (=[1, 2, 3])"
* ```
*/
export function stringifyEnumArray<E extends EnumOrObject>(obj: E, enums: E[keyof E][]): string {
if (obj.length === 0) {
return "[]";
}
const vals = enums.slice();
/** An array of string names */
let names: string[];
if (obj[enums[0]] !== undefined) {
// Reverse mapping exists - `obj` is a `TSNumericEnum` and its reverse mapped counterparts are strings
names = enums.map(e => (obj as TSNumericEnum<E>)[e] as string);
} else {
// No reverse mapping exists means `obj` is a `NormalEnum`.
// NB: This (while ugly) should be more ergonomic than doing a repeated lookup for large `const object`s
// as the `enums` array should be significantly shorter than the corresponding enum type.
names = [];
for (const [k, v] of Object.entries(obj as NormalEnum<E>)) {
if (names.length === enums.length) {
// No more names to get
break;
}
// Find all matches for the given enum, assigning their keys to the names array
findIndices(enums, v).forEach(matchIndex => {
names[matchIndex] = k;
});
}
}
return `[${names.join(", ")}] (=[${vals.join(", ")}])`;
}
/**
* Return the indices of all occurrences of a value in an array.
* @param arr - The array to search
* @param searchElement - The value to locate in the array
* @param fromIndex - The array index at which to begin the search. If fromIndex is omitted, the
* search starts at index 0
*/
function findIndices<T>(arr: T[], searchElement: T, fromIndex = 0): number[] {
const indices: number[] = [];
const arrSliced = arr.slice(fromIndex);
for (const [index, value] of arrSliced.entries()) {
if (value === searchElement) {
indices.push(index);
}
}
return indices;
}
/**
* Convert a number into an English ordinal
* @param num - The number to convert into an ordinal
* @returns The ordinal representation of {@linkcode num}.
* @example
* ```ts
* console.log(getOrdinal(1)); // Output: "1st"
* console.log(getOrdinal(12)); // Output: "12th"
* console.log(getOrdinal(24)); // Output: "24th"
* ```
*/
export function getOrdinal(num: number): string {
const tens = num % 10;
const hundreds = num % 100;
if (tens === 1 && hundreds !== 11) {
return num + "st";
}
if (tens === 2 && hundreds !== 12) {
return num + "nd";
}
if (tens === 3 && hundreds !== 13) {
return num + "rd";
}
return num + "th";
}
/**
* Get the localized name of a {@linkcode Stat}.
* @param s - The {@linkcode Stat} to check
* @returns - The proper name for s, retrieved from the translations.
*/
export function getStatName(s: Stat): string {
return i18next.t(getStatKey(s));
}
/**
* Convert an object into a oneline diff to be shown in an error message.
* @param obj - The object to return the oneline diff of
* @returns The updated diff
*/
export function getOnelineDiffStr(this: MatcherState, obj: unknown): string {
return this.utils
.stringify(obj, undefined, { maxLength: 35, indent: 0, printBasicPrototype: false })
.replace(/\n/g, " ") // Replace newlines with spaces
.replace(/,(\s*)}$/g, "$1}");
}

View File

@ -1,3 +1,5 @@
import { Pokemon } from "#field/pokemon";
import type { GameManager } from "#test/test-utils/game-manager";
import i18next, { type ParseKeys } from "i18next";
import { vi } from "vitest";
@ -29,3 +31,54 @@ export function arrayOfRange(start: number, end: number) {
export function getApiBaseUrl() {
return import.meta.env.VITE_SERVER_URL ?? "http://localhost:8001";
}
type TypeOfResult = "undefined" | "object" | "boolean" | "number" | "bigint" | "string" | "symbol" | "function";
/**
* Helper to determine the actual type of the received object as human readable string
* @param received - The received object
* @returns A human readable string of the received object (type)
*/
export function receivedStr(received: unknown, expectedType: TypeOfResult = "object"): string {
if (received === null) {
return "null";
}
if (received === undefined) {
return "undefined";
}
if (typeof received !== expectedType) {
return typeof received;
}
if (expectedType === "object") {
return received.constructor.name;
}
return "unknown";
}
/**
* Helper to check if the received object is an {@linkcode object}
* @param received - The object to check
* @returns Whether the object is an {@linkcode object}.
*/
function isObject(received: unknown): received is object {
return received !== null && typeof received === "object";
}
/**
* Helper function to check if a given object is a {@linkcode Pokemon}.
* @param received - The object to check
* @return Whether `received` is a {@linkcode Pokemon} instance.
*/
export function isPokemonInstance(received: unknown): received is Pokemon {
return isObject(received) && received instanceof Pokemon;
}
/**
* Checks if an object is a {@linkcode GameManager} instance
* @param received - The object to check
* @returns Whether the object is a {@linkcode GameManager} instance.
*/
export function isGameManagerInstance(received: unknown): received is GameManager {
return isObject(received) && (received as GameManager).constructor.name === "GameManager";
}

View File

@ -0,0 +1,38 @@
import type { AtLeastOne } from "#types/type-helpers";
import { describe, it } from "node:test";
import { expectTypeOf } from "vitest";
type fakeObj = {
foo: number;
bar: string;
baz: number | string;
};
type optionalObj = {
foo: number;
bar: string;
baz?: number | string;
};
describe("AtLeastOne", () => {
it("should accept an object with at least 1 of its defined parameters", () => {
expectTypeOf<{ foo: number }>().toExtend<AtLeastOne<fakeObj>>();
expectTypeOf<{ bar: string }>().toExtend<AtLeastOne<fakeObj>>();
expectTypeOf<{ baz: number | string }>().toExtend<AtLeastOne<fakeObj>>();
});
it("should convert to a partial intersection with the union of all individual single properties", () => {
expectTypeOf<AtLeastOne<fakeObj>>().branded.toEqualTypeOf<
Partial<fakeObj> & ({ foo: number } | { bar: string } | { baz: number | string })
>();
});
it("should treat optional properties as required", () => {
expectTypeOf<AtLeastOne<fakeObj>>().branded.toEqualTypeOf<AtLeastOne<optionalObj>>();
});
it("should not accept empty objects, even if optional properties are present", () => {
expectTypeOf<Record<string, never>>().not.toExtend<AtLeastOne<fakeObj>>();
expectTypeOf<Record<string, never>>().not.toExtend<AtLeastOne<optionalObj>>();
});
});