Partially ported over pkty matchers (WIP)

This commit is contained in:
Bertie690 2025-07-27 16:41:59 -04:00
parent 7cb2c560ab
commit 917fb596b4
20 changed files with 933 additions and 31 deletions

View File

@ -41,7 +41,7 @@ export type Mutable<T> = {
* @typeParam O - The type of the object
* @typeParam V - The type of one of O's values
*/
export type InferKeys<O extends Record<keyof any, unknown>, V extends EnumValues<O>> = {
export type InferKeys<O, V extends EnumValues<O>> = {
[K in keyof O]: O[K] extends V ? K : never;
}[keyof O];

View File

@ -2,19 +2,29 @@ import type { Pokemon } from "#field/pokemon";
import type { PokemonType } from "#enums/pokemon-type";
import type { expect } from "vitest";
import type { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types";
import type { AbilityId } from "#enums/ability-id";
import type { BattlerTagType } from "#enums/battler-tag-type";
import type { MoveId } from "#enums/move-id";
import type { BattleStat, EffectiveStat, Stat } from "#enums/stat";
import type { StatusEffect } from "#enums/status-effect";
import type { TerrainType } from "#app/data/terrain";
import type { WeatherType } from "#enums/weather-type";
import type { ToHaveEffectiveStatMatcherOptions } from "#test/test-utils/matchers/to-have-effective-stat";
import { TurnMove } from "#types/turn-move";
declare module "vitest" {
interface Assertion {
/**
* Matcher to check if 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.
*
@ -22,5 +32,87 @@ declare module "vitest" {
* @param options - The options passed to the matcher.
*/
toHaveTypes(expected: 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}
* @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 | Partial<TurnMove>, index?: number): void;
/**
* Matcher to check if a {@linkcode Pokemon Pokemon's} effective stat is as expected
* (checked after all mstat 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;
/**
* Matcher to check if 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;
/**
* Matcher to check if a {@linkcode Pokemon} has a specific {@linkcode StatusEffect | non-volatile status effect}.
* @param expectedStatusEffect - The expected {@linkcode StatusEffect}
*/
toHaveStatusEffect(expectedStatusEffect: StatusEffect): void;
/**
* Matcher to check if the current {@linkcode WeatherType} is as expected.
* @param expectedWeatherType - The expected {@linkcode WeatherType}
*/
toHaveWeather(expectedWeatherType: WeatherType): void;
/**
* Matcher to check if the current {@linkcode TerrainType} is as expected.
* @param expectedTerrainType - The expected {@linkcode TerrainType}
*/
toHaveTerrain(expectedTerrainType: TerrainType): void;
/**
* Matcher to check if a {@linkcode Pokemon} has full HP.
*/
toHaveFullHp(): void;
/**
* Matcher to check if 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;
/**
* Matcher to check if a {@linkcode Pokemon} has a specific {@linkcode BattlerTagType}.
* @param expectedBattlerTagType - The expected {@linkcode BattlerTagType}
*/
toHaveBattlerTag(expectedBattlerTagType: BattlerTagType): void;
/**
* Matcher to check if a {@linkcode Pokemon} had a specific {@linkcode AbilityId} applied.
* @param expectedAbilityId - The expected {@linkcode AbilityId}
*/
toHaveAbilityApplied(expectedAbilityId: AbilityId): void;
/**
* Matcher to check if a {@linkcode Pokemon} has a specific amount of HP.
* @param expectedHp - The expected amount of {@linkcode Stat.HP | HP} to have
*/
toHaveHp(expectedHp: number): void;
/**
* Matcher to check if a {@linkcode Pokemon} has fainted (as determined by {@linkcode Pokemon.isFainted}).
*/
toHaveFainted(): void;
}
}

View File

@ -1,5 +1,18 @@
import { toEqualArrayUnsorted } from "#test/test-utils/matchers/to-equal-array-unsorted";
import { toHaveAbilityAppliedMatcher } from "#test/test-utils/matchers/to-have-ability-applied";
import { toHaveBattlerTag } from "#test/test-utils/matchers/to-have-battler-tag";
import { toHaveEffectiveStatMatcher } from "#test/test-utils/matchers/to-have-effective-stat";
import { toHaveFaintedMatcher } from "#test/test-utils/matchers/to-have-fainted";
import { toHaveFullHpMatcher } from "#test/test-utils/matchers/to-have-full-hp";
import { toHaveHpMatcher } from "#test/test-utils/matchers/to-have-hp";
import { toHaveMoveResultMatcher } from "#test/test-utils/matchers/to-have-move-result-matcher";
import { toHaveStatStageMatcher } from "#test/test-utils/matchers/to-have-stat-stage-matcher";
import { toHaveStatusEffectMatcher } from "#test/test-utils/matchers/to-have-status-effect-matcher";
import { toHaveTakenDamageMatcher } from "#test/test-utils/matchers/to-have-taken-damage-matcher";
import { toHaveTerrainMatcher } from "#test/test-utils/matchers/to-have-terrain-matcher";
import { toHaveTypes } from "#test/test-utils/matchers/to-have-types";
import { toHaveUsedMoveMatcher } from "#test/test-utils/matchers/to-have-used-move-matcher";
import { toHaveWeatherMatcher } from "#test/test-utils/matchers/to-have-weather-matcher";
import { expect } from "vitest";
/*
@ -10,4 +23,17 @@ import { expect } from "vitest";
expect.extend({
toEqualArrayUnsorted,
toHaveTypes,
toHaveMoveResult: toHaveMoveResultMatcher,
toHaveUsedMove: toHaveUsedMoveMatcher,
toHaveEffectiveStat: toHaveEffectiveStatMatcher,
toHaveTakenDamage: toHaveTakenDamageMatcher,
toHaveWeather: toHaveWeatherMatcher,
toHaveTerrain: toHaveTerrainMatcher,
toHaveFullHp: toHaveFullHpMatcher,
toHaveStatusEffect: toHaveStatusEffectMatcher,
toHaveStatStage: toHaveStatStageMatcher,
toHaveBattlerTag: toHaveBattlerTag,
toHaveAbilityApplied: toHaveAbilityAppliedMatcher,
toHaveHp: toHaveHpMatcher,
toHaveFainted: toHaveFaintedMatcher,
});

View File

@ -0,0 +1,8 @@
import { PokemonType } from "#enums/pokemon-type";
import { describe, expect, it } from "vitest";
describe("a", () => {
it("r", () => {
expect(1).toHaveTypes([PokemonType.FLYING]);
});
});

View File

@ -2,28 +2,29 @@ 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
* @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 {
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)}!`,
pass: false,
message: () => `Expected to receive 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}!`,
actual: received,
expected,
};
@ -34,7 +35,7 @@ export function toEqualArrayUnsorted(this: MatcherState, received: unknown, expe
const pass = this.equals(gotSorted, wantSorted, [...this.customTesters, this.utils.iterableEquality]);
return {
pass: this.isNot !== pass,
pass,
message: () =>
`Expected ${this.utils.stringify(received)} to exactly equal ${this.utils.stringify(expected)} without order!`,
actual: gotSorted,

View File

@ -0,0 +1,42 @@
/* 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 { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check if a {@linkcode Pokemon} had a specific {@linkcode AbilityId} applied.
* @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 toHaveAbilityAppliedMatcher(
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 = `${AbilityId[expectedAbilityId]} (=${expectedAbilityId})`;
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have applied ${expectedAbilityStr}, but it did!`
: `Expected ${pkmName} to have applied ${expectedAbilityStr}, but it did not!`,
actual: received.waveData.abilitiesApplied,
expected: expectedAbilityId,
};
}

View File

@ -0,0 +1,47 @@
/* 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, stringifyEnumArray } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check 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);
// "the SEEDED BattlerTag (=1)"
const expectedTagStr = getEnumStr(BattlerTagType, expectedBattlerTagType, { prefix: "the ", suffix: " BattlerTag" });
const actualTagStr = stringifyEnumArray(
BattlerTagType,
received.summonData.tags.map(t => t.tagType),
);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have ${expectedTagStr}, but it did!`
: `Expected ${pkmName} to have ${expectedTagStr}, but it did not!`,
actual: actualTagStr,
expected: getEnumStr(BattlerTagType, expectedBattlerTagType),
};
}

View File

@ -0,0 +1,65 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { type EffectiveStat, getStatKey } from "#enums/stat";
import type { Pokemon } from "#field/pokemon";
import type { Move } from "#moves/move";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
import i18next from "i18next";
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 to check if a {@linkcode Pokemon}'s effective stat equals the expected 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 toHaveEffectiveStatMatcher(
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)}!`,
};
}
const actualValue = received.getEffectiveStat(stat, enemy, move, undefined, undefined, undefined, isCritical);
const pass = actualValue === expectedValue;
const pkmName = getPokemonNameWithAffix(received);
const statName = i18next.t(getStatKey(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,31 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check if a Pokemon has fainted
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @returns Whether the matcher passed
*/
export function toHaveFaintedMatcher(this: MatcherState, received: unknown): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const { hp } = received;
const maxHp = received.getMaxHp();
const pass = received.isFainted();
const pkmName = getPokemonNameWithAffix(received);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} NOT to have fainted, but it did! (${hp}/${maxHp} HP)`
: `Expected ${pkmName} to have fainted, but it did not. (${hp}/${maxHp} HP)`,
};
}

View File

@ -0,0 +1,30 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check if a Pokemon is full hp.
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @returns Whether the matcher passed
*/
export function toHaveFullHpMatcher(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() === true;
const ofHpStr = `${received.getInverseHp()}/${received.getMaxHp()} HP`;
const pkmName = getPokemonNameWithAffix(received);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have full hp (${ofHpStr}), but it did!`
: `Expected ${pkmName} to have full hp, but found ${ofHpStr}.`,
};
}

View File

@ -0,0 +1,32 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check 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 toHaveHpMatcher(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);
const maxHp = received.getMaxHp();
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have ${expectedHp}/${maxHp} HP, but it did!`
: `Expected ${pkmName} to have ${expectedHp}/${maxHp} HP, but found ${actualHp}/${maxHp} HP.`,
};
}

View File

@ -0,0 +1,48 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { type BattleStat, Stat } from "#enums/stat";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check if a Pokemon has a specific {@linkcode Stat} stage
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @param stat - The {@linkcode 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 toHaveStatStageMatcher(
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 = Stat[stat];
return {
pass,
message: () =>
pass
? `Expected ${pkmName}'s ${statName} stat stage to NOT be ${expectedStage}, but it was!`
: `Expected ${pkmName}'s ${statName} stage to be ${expectedStage}, but got ${actualStage}!`,
actual: actualStage,
expected: expectedStage,
};
}

View File

@ -0,0 +1,65 @@
/* 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 { Status } from "#data/status-effect";
import { StatusEffect } from "#enums/status-effect";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { NonFunctionPropertiesRecursive } from "#types/type-helpers";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export type expectedType =
| StatusEffect
| { effect: StatusEffect.TOXIC; toxicTurnCount: number }
| { effect: StatusEffect.SLEEP; sleepTurnsRemaining: number };
/**
* Matcher to check 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 toHaveStatusEffectMatcher(
this: MatcherState,
received: unknown,
expectedStatus: expectedType,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
// Convert to Status
const expStatus: { effect: StatusEffect } & Partial<NonFunctionPropertiesRecursive<Status>> =
typeof expectedStatus === "number"
? {
effect: expectedStatus,
}
: expectedStatus;
// If expected to have no status,
if (expStatus.effect === StatusEffect.NONE) {
k;
}
const actualStatus = received.status;
const pass = this.equals(received, expectedStatus, [
...this.customTesters,
this.utils.subsetEquality,
this.utils.iterableEquality,
]);
const pkmName = getPokemonNameWithAffix(received);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} NOT to have ${expectedStatusEffectStr}, but it did!`
: `Expected ${pkmName} to have status effect: ${expectedStatusEffectStr}, but got: ${actualStatusEffectStr}!`,
};
}

View File

@ -0,0 +1,49 @@
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";
//#region Types
export interface ToHaveTakenDamageMatcherOptions {
/** Whether to skip the internal {@linkcode toDmgValue} call. @defaultValue false */
skipToDmgValue?: boolean;
} //#endregion
/**
* Matcher to check 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 toHaveTakenDamageMatcher(
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}!`,
expected: expectedDmgValue,
actual: actualDmgValue,
};
}

View File

@ -0,0 +1,58 @@
import { TerrainType } from "#app/data/terrain";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import { toReadableString } from "#utils/common";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check 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 toHaveTerrainMatcher(
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}!`,
actual: actualStr,
expected: expectedStr,
};
}
/**
* 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";
}
// TODO: Change to use updated string utils
return toReadableString(TerrainType[terrainType] + " 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 {
/**
@ -26,39 +29,39 @@ export function toHaveTypes(
expected: unknown,
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)}!`,
pass: false,
message: () => `Expected to receive an array with length >=1, but got ${this.utils.stringify(expected)}!`,
};
}
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)}!`,
pass: false,
message: () => `Expected to receive array of PokemonTypes but got ${this.utils.stringify(expected)}!`,
};
}
const gotSorted = pkmnTypeToStr(received.getTypes(...(options.args ?? [])));
const wantSorted = pkmnTypeToStr(expected.slice());
const pass = this.equals(gotSorted, wantSorted, [...this.customTesters, this.utils.iterableEquality]);
const actualSorted = stringifyEnumArray(PokemonType, received.getTypes(...(options.args ?? [])).sort());
const expectedSorted = stringifyEnumArray(PokemonType, expected.slice().sort());
const matchers = options.exact
? [...this.customTesters, this.utils.iterableEquality]
: [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality];
const pass = this.equals(actualSorted, expectedSorted, matchers);
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: () =>
`Expected ${getPokemonNameWithAffix(received)} to have types ${this.utils.stringify(expectedSorted)}, but got ${actualSorted}!`,
actual: actualSorted,
expected: expectedSorted,
};
}
function pkmnTypeToStr(p: PokemonType[]): string[] {
return p.sort().map(type => PokemonType[type]);
}

View File

@ -0,0 +1,64 @@
import { getPokemonNameWithAffix } from "#app/messages";
import type { MoveId } from "#enums/move-id";
// biome-ignore lint/correctness/noUnusedImports: TSDocs
import type { Pokemon } from "#field/pokemon";
import { 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 { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check if a {@linkcode Pokemon} has used a specific {@linkcode MoveId} at the given .
* @param received - The actual value received. Should be a {@linkcode Pokemon}
* @param expectedValue - The expected value; can be a {@linkcode MoveId} or a partially filled {@linkcode TurnMove}
* @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 toHaveUsedMoveMatcher(
this: MatcherState,
received: unknown,
expectedResult: MoveId | Partial<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,
]);
return {
pass,
message: () =>
pass
? `Expected ${pkmName}'s ${moveIndexStr} NOT to match ${this.utils.stringify(expectedResult)}, but it did!`
: `Expected ${pkmName}'s ${moveIndexStr} to match ${this.utils.stringify(expectedResult)}, but got ${this.utils.stringify(move)}!`,
expected: expectedResult,
actual: move,
};
}

View File

@ -0,0 +1,59 @@
import { WeatherType } from "#enums/weather-type";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import { toReadableString } from "#utils/common";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check 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 toHaveWeatherMatcher(
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}!`,
actual: actualStr,
expected: expectedStr,
};
}
/**
* 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";
}
// TODO: Change to use updated string utils
return toReadableString(WeatherType[weatherType]);
}

View File

@ -0,0 +1,129 @@
import type { EnumOrObject, EnumValues, NormalEnum, TSNumericEnum } from "#types/enum-types";
import { toReadableString } from "#utils/common";
import { enumValueToKey } from "#utils/enums";
type Casing = "Preserve" | "Title";
interface getEnumStrOptions {
/**
* A string denoting the casing method to use.
* @defaultValue "Preserve"
*/
casing?: Casing;
/**
* If present, will be added to the beginning of the enum string.
*/
prefix?: string;
/**
* If present, will be added to the end of the enum string.
*/
suffix?: string;
}
/**
* Helper function to 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 suffix - 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,
* }
* console.log(getEnumStr(fakeEnum, fakeEnum.ONE)); // Output: "ONE (=1)"
* console.log(getEnumStr(fakeEnum, fakeEnum.TWO, {case: "Title", suffix: " Terrain"})); // Output: "Two Terrain (=2)"
* ```
*/
export function getEnumStr<E extends EnumOrObject>(
obj: E,
val: EnumValues<E>,
{ casing = "Preserve", prefix = "", suffix = "" }: getEnumStrOptions = {},
): string {
let casingFunc: ((s: string) => string) | undefined;
switch (casing) {
case "Preserve":
break;
case "Title":
casingFunc = toReadableString;
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();
let names: string[];
if (obj[enums[0]] !== undefined) {
// Reverse mapping exists - `obj` is a `TSNumericEnum` and its reverse mapped counterparts
names = enums.map(e => (obj as TSNumericEnum<E>)[e] as string);
} else {
// No reverse mapping exists means `obj` is a `NormalEnum`
names = enums.map(e => enumValueToKey(obj as NormalEnum<E>, e) as string);
}
return `[${names.join(", ")}] (=[${vals.join(", ")}])`;
}
/**
* 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";
}

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";
}