From 917fb596b445343c0df8c8db91dcc5db2c077538 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 27 Jul 2025 16:41:59 -0400 Subject: [PATCH] Partially ported over pkty matchers (WIP) --- src/@types/type-helpers.ts | 2 +- test/@types/vitest.d.ts | 98 ++++++++++++- test/matchers.setup.ts | 26 ++++ test/test-utils/matchers/a.test.ts | 8 ++ .../matchers/to-equal-array-unsorted.ts | 17 +-- .../matchers/to-have-ability-applied.ts | 42 ++++++ .../matchers/to-have-battler-tag.ts | 47 +++++++ .../matchers/to-have-effective-stat.ts | 65 +++++++++ test/test-utils/matchers/to-have-fainted.ts | 31 +++++ test/test-utils/matchers/to-have-full-hp.ts | 30 ++++ test/test-utils/matchers/to-have-hp.ts | 32 +++++ .../matchers/to-have-stat-stage-matcher.ts | 48 +++++++ .../matchers/to-have-status-effect-matcher.ts | 65 +++++++++ .../matchers/to-have-taken-damage-matcher.ts | 49 +++++++ .../matchers/to-have-terrain-matcher.ts | 58 ++++++++ test/test-utils/matchers/to-have-types.ts | 41 +++--- .../matchers/to-have-used-move-matcher.ts | 64 +++++++++ .../matchers/to-have-weather-matcher.ts | 59 ++++++++ test/test-utils/string-utils.ts | 129 ++++++++++++++++++ test/test-utils/test-utils.ts | 53 +++++++ 20 files changed, 933 insertions(+), 31 deletions(-) create mode 100644 test/test-utils/matchers/a.test.ts create mode 100644 test/test-utils/matchers/to-have-ability-applied.ts create mode 100644 test/test-utils/matchers/to-have-battler-tag.ts create mode 100644 test/test-utils/matchers/to-have-effective-stat.ts create mode 100644 test/test-utils/matchers/to-have-fainted.ts create mode 100644 test/test-utils/matchers/to-have-full-hp.ts create mode 100644 test/test-utils/matchers/to-have-hp.ts create mode 100644 test/test-utils/matchers/to-have-stat-stage-matcher.ts create mode 100644 test/test-utils/matchers/to-have-status-effect-matcher.ts create mode 100644 test/test-utils/matchers/to-have-taken-damage-matcher.ts create mode 100644 test/test-utils/matchers/to-have-terrain-matcher.ts create mode 100644 test/test-utils/matchers/to-have-used-move-matcher.ts create mode 100644 test/test-utils/matchers/to-have-weather-matcher.ts create mode 100644 test/test-utils/string-utils.ts diff --git a/src/@types/type-helpers.ts b/src/@types/type-helpers.ts index 3a5c88e3f15..f06f16ef86e 100644 --- a/src/@types/type-helpers.ts +++ b/src/@types/type-helpers.ts @@ -41,7 +41,7 @@ export type Mutable = { * @typeParam O - The type of the object * @typeParam V - The type of one of O's values */ -export type InferKeys, V extends EnumValues> = { +export type InferKeys> = { [K in keyof O]: O[K] extends V ? K : never; }[keyof O]; diff --git a/test/@types/vitest.d.ts b/test/@types/vitest.d.ts index 58b36580727..61f8b422199 100644 --- a/test/@types/vitest.d.ts +++ b/test/@types/vitest.d.ts @@ -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(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, 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; } } \ No newline at end of file diff --git a/test/matchers.setup.ts b/test/matchers.setup.ts index 03d9dd342e4..6bddbbe0567 100644 --- a/test/matchers.setup.ts +++ b/test/matchers.setup.ts @@ -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, }); diff --git a/test/test-utils/matchers/a.test.ts b/test/test-utils/matchers/a.test.ts new file mode 100644 index 00000000000..95278533534 --- /dev/null +++ b/test/test-utils/matchers/a.test.ts @@ -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]); + }); +}); diff --git a/test/test-utils/matchers/to-equal-array-unsorted.ts b/test/test-utils/matchers/to-equal-array-unsorted.ts index 0627623bbd9..c3388765841 100644 --- a/test/test-utils/matchers/to-equal-array-unsorted.ts +++ b/test/test-utils/matchers/to-equal-array-unsorted.ts @@ -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, diff --git a/test/test-utils/matchers/to-have-ability-applied.ts b/test/test-utils/matchers/to-have-ability-applied.ts new file mode 100644 index 00000000000..234cb6831c3 --- /dev/null +++ b/test/test-utils/matchers/to-have-ability-applied.ts @@ -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, + }; +} diff --git a/test/test-utils/matchers/to-have-battler-tag.ts b/test/test-utils/matchers/to-have-battler-tag.ts new file mode 100644 index 00000000000..304fe8f2626 --- /dev/null +++ b/test/test-utils/matchers/to-have-battler-tag.ts @@ -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), + }; +} diff --git a/test/test-utils/matchers/to-have-effective-stat.ts b/test/test-utils/matchers/to-have-effective-stat.ts new file mode 100644 index 00000000000..1041682033d --- /dev/null +++ b/test/test-utils/matchers/to-have-effective-stat.ts @@ -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, + }; +} diff --git a/test/test-utils/matchers/to-have-fainted.ts b/test/test-utils/matchers/to-have-fainted.ts new file mode 100644 index 00000000000..1721379760d --- /dev/null +++ b/test/test-utils/matchers/to-have-fainted.ts @@ -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)`, + }; +} diff --git a/test/test-utils/matchers/to-have-full-hp.ts b/test/test-utils/matchers/to-have-full-hp.ts new file mode 100644 index 00000000000..e8ed8b78364 --- /dev/null +++ b/test/test-utils/matchers/to-have-full-hp.ts @@ -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}.`, + }; +} diff --git a/test/test-utils/matchers/to-have-hp.ts b/test/test-utils/matchers/to-have-hp.ts new file mode 100644 index 00000000000..36bf6b90a68 --- /dev/null +++ b/test/test-utils/matchers/to-have-hp.ts @@ -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.`, + }; +} diff --git a/test/test-utils/matchers/to-have-stat-stage-matcher.ts b/test/test-utils/matchers/to-have-stat-stage-matcher.ts new file mode 100644 index 00000000000..768a4e61421 --- /dev/null +++ b/test/test-utils/matchers/to-have-stat-stage-matcher.ts @@ -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, + }; +} diff --git a/test/test-utils/matchers/to-have-status-effect-matcher.ts b/test/test-utils/matchers/to-have-status-effect-matcher.ts new file mode 100644 index 00000000000..41e0a25fa80 --- /dev/null +++ b/test/test-utils/matchers/to-have-status-effect-matcher.ts @@ -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> = + 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}!`, + }; +} diff --git a/test/test-utils/matchers/to-have-taken-damage-matcher.ts b/test/test-utils/matchers/to-have-taken-damage-matcher.ts new file mode 100644 index 00000000000..12ee8e0722e --- /dev/null +++ b/test/test-utils/matchers/to-have-taken-damage-matcher.ts @@ -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, + }; +} diff --git a/test/test-utils/matchers/to-have-terrain-matcher.ts b/test/test-utils/matchers/to-have-terrain-matcher.ts new file mode 100644 index 00000000000..52fd1d176e9 --- /dev/null +++ b/test/test-utils/matchers/to-have-terrain-matcher.ts @@ -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"); +} diff --git a/test/test-utils/matchers/to-have-types.ts b/test/test-utils/matchers/to-have-types.ts index d09f4fc5f76..4016e1b4ab7 100644 --- a/test/test-utils/matchers/to-have-types.ts +++ b/test/test-utils/matchers/to-have-types.ts @@ -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]); -} diff --git a/test/test-utils/matchers/to-have-used-move-matcher.ts b/test/test-utils/matchers/to-have-used-move-matcher.ts new file mode 100644 index 00000000000..ff1e0d95ce4 --- /dev/null +++ b/test/test-utils/matchers/to-have-used-move-matcher.ts @@ -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, + 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, + }; +} diff --git a/test/test-utils/matchers/to-have-weather-matcher.ts b/test/test-utils/matchers/to-have-weather-matcher.ts new file mode 100644 index 00000000000..7bc9fe52bbc --- /dev/null +++ b/test/test-utils/matchers/to-have-weather-matcher.ts @@ -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]); +} diff --git a/test/test-utils/string-utils.ts b/test/test-utils/string-utils.ts new file mode 100644 index 00000000000..1b0999237f3 --- /dev/null +++ b/test/test-utils/string-utils.ts @@ -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( + obj: E, + val: EnumValues, + { 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, 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(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] as string); + } else { + // No reverse mapping exists means `obj` is a `NormalEnum` + names = enums.map(e => enumValueToKey(obj as NormalEnum, 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"; +} diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 40e4bbe8775..b9e73c3e9da 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -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"; +}