diff --git a/test/@types/vitest.d.ts b/test/@types/vitest.d.ts index 7b756c45a57..409a874ba84 100644 --- a/test/@types/vitest.d.ts +++ b/test/@types/vitest.d.ts @@ -1,20 +1,27 @@ import type { TerrainType } from "#app/data/terrain"; +import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag"; import type { AbilityId } from "#enums/ability-id"; +import type { ArenaTagType } from "#enums/arena-tag-type"; import type { BattlerTagType } from "#enums/battler-tag-type"; import type { MoveId } from "#enums/move-id"; import type { PokemonType } from "#enums/pokemon-type"; import type { BattleStat, EffectiveStat, Stat } from "#enums/stat"; import type { StatusEffect } from "#enums/status-effect"; import type { WeatherType } from "#enums/weather-type"; +import type { Arena } from "#field/arena"; 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 { toDmgValue } from "utils/common"; import type { expect } from "vitest"; +import "vitest"; import type Overrides from "#app/overrides"; +import type { ArenaTagSide } from "#enums/arena-tag-side"; import type { PokemonMove } from "#moves/pokemon-move"; +import type { OneOther } from "#test/@types/test-helpers"; declare module "vitest" { interface Assertion { @@ -35,6 +42,7 @@ declare module "vitest" { * @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; /** @@ -79,6 +87,24 @@ declare module "vitest" { */ toHaveTerrain(expectedTerrainType: TerrainType): void; + /** + * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}. + * + * @param expectedType - A partially-filled {@linkcode ArenaTag} containing the desired properties + */ + toHaveArenaTag( + expectedType: OneOther & { tagType: T }, // intersection required bc this doesn't preserve T + ): void; + /** + * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}. + * + * @param expectedType - The {@linkcode ArenaTagType} of the desired tag + * @param side - The {@linkcode ArenaTagSide | side of the field} the tag should affect, or + * {@linkcode ArenaTagSide.BOTH} to check both sides; + * default `ArenaTagSide.BOTH` + */ + toHaveArenaTag(expectedType: ArenaTagType, side?: ArenaTagSide): void; + /** * Check whether a {@linkcode Pokemon} is at full HP. */ diff --git a/test/matchers.setup.ts b/test/matchers.setup.ts index 03b29302916..447911c1d87 100644 --- a/test/matchers.setup.ts +++ b/test/matchers.setup.ts @@ -1,5 +1,6 @@ import { toEqualArrayUnsorted } from "#test/test-utils/matchers/to-equal-array-unsorted"; import { toHaveAbilityApplied } from "#test/test-utils/matchers/to-have-ability-applied"; +import { toHaveArenaTag } from "#test/test-utils/matchers/to-have-arena-tag"; 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"; @@ -28,6 +29,7 @@ expect.extend({ toHaveTakenDamage, toHaveWeather, toHaveTerrain, + toHaveArenaTag, toHaveFullHp, toHaveStatusEffect, toHaveStatStage, diff --git a/test/test-utils/matchers/to-have-arena-tag.ts b/test/test-utils/matchers/to-have-arena-tag.ts new file mode 100644 index 00000000000..fd1a696c4df --- /dev/null +++ b/test/test-utils/matchers/to-have-arena-tag.ts @@ -0,0 +1,80 @@ +import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag"; +import type { ArenaTagSide } from "#enums/arena-tag-side"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import type { OneOther } from "#test/@types/test-helpers"; +// biome-ignore lint/correctness/noUnusedImports: TSDoc +import type { GameManager } from "#test/test-utils/game-manager"; +import { getEnumStr, getOnelineDiffStr, stringifyEnumArray } from "#test/test-utils/string-utils"; +import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils"; +import type { NonFunctionPropertiesRecursive } from "#types/type-helpers"; +import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; + +export type toHaveArenaTagOptions = OneOther; + +/** + * Matcher to check if the {@linkcode Arena} has a given {@linkcode ArenaTag} active. + * @param received - The object to check. Should be the current {@linkcode GameManager}. + * @param expectedType - The {@linkcode ArenaTagType} of the desired tag, or a partially-filled object + * containing the desired properties + * @param side - The {@linkcode ArenaTagSide | side of the field} the tag should affect, or + * {@linkcode ArenaTagSide.BOTH} to check both sides + * @returns The result of the matching + */ +export function toHaveArenaTag( + this: MatcherState, + received: unknown, + // simplified types used for brevity; full overloads are in `vitest.d.ts` + expectedType: T | (Partial> & { tagType: T; side: ArenaTagSide }), + side?: ArenaTagSide, +): SyncExpectationResult { + if (!isGameManagerInstance(received)) { + return { + pass: this.isNot, + message: () => `Expected to recieve a GameManager, but got ${receivedStr(received)}!`, + }; + } + + if (!received.scene?.arena) { + return { + pass: false, + message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`, + }; + } + + if (typeof expectedType === "string") { + // Coerce lone `tagType`s into objects + // Bangs are ok as we enforce safety via overloads + expectedType = { tagType: expectedType, side: side! }; + } + + // We need to get all tags for the case of checking properties of a tag present on both sides of the arena + const tags = received.scene.arena.findTagsOnSide(t => t.tagType === expectedType.tagType, expectedType.side); + if (!tags.length) { + const expectedStr = getEnumStr(ArenaTagType, expectedType.tagType); + return { + pass: false, + message: () => `Expected the arena to have a tag matching ${expectedStr}, but it didn't!`, + expected: getEnumStr(ArenaTagType, expectedType.tagType), + actual: stringifyEnumArray( + ArenaTagType, + received.scene.arena.tags.map(t => t.tagType), + ), + }; + } + + // Pass if any of the matching tags meet our criteria + const pass = tags.some(tag => + this.equals(tag, expectedType, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]), + ); + + const expectedStr = getOnelineDiffStr.call(this, expectedType); + return { + pass, + message: () => + pass + ? `Expected the arena to NOT have a tag matching ${expectedStr}, but it did!` + : `Expected the arena to have a tag matching ${expectedStr}, but it didn't!`, + expected: expectedType, + actual: tags, + }; +} diff --git a/test/test-utils/matchers/to-have-status-effect.ts b/test/test-utils/matchers/to-have-status-effect.ts index a46800632f3..9bcc0b5097f 100644 --- a/test/test-utils/matchers/to-have-status-effect.ts +++ b/test/test-utils/matchers/to-have-status-effect.ts @@ -37,10 +37,8 @@ export function toHaveStatusEffect( const actualEffect = received.status?.effect ?? StatusEffect.NONE; // Check exclusively effect equality first, coercing non-matching status effects to numbers. - if (actualEffect !== (expectedStatus as Exclude)?.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).effect; + if (typeof expectedStatus === "object" && actualEffect !== expectedStatus.effect) { + expectedStatus = expectedStatus.effect; } if (typeof expectedStatus === "number") {