diff --git a/test/@types/test-helpers.ts b/test/@types/test-helpers.ts new file mode 100644 index 00000000000..4db85edcb5d --- /dev/null +++ b/test/@types/test-helpers.ts @@ -0,0 +1,27 @@ +import type { AtLeastOne, NonFunctionPropertiesRecursive as nonFunc } from "#types/type-helpers"; + +/** + * Helper type to admit an object containing the given properties + * _and at least 1 other non-function property_. + * @example + * ```ts + * type foo = { + * qux: 1 | 2 | 3, + * bar: number, + * baz: string + * quux: () => void; // ignored! + * } + * + * type quxAndSomethingElse = OneOther + * + * const good1: quxAndSomethingElse = {qux: 1, bar: 3} // OK! + * const good2: quxAndSomethingElse = {qux: 2, baz: "4", bar: 12} // OK! + * const bad1: quxAndSomethingElse = {baz: "4", bar: 12} // Errors because `qux` is required + * const bad2: quxAndSomethingElse = {qux: 1} // Errors because at least 1 thing _other_ than `qux` is required + * ``` + * @typeParam O - The object to source keys from + * @typeParam K - One or more of O's keys to render mandatory + */ +export type OneOther = AtLeastOne, K>> & { + [key in K]: O[K]; +}; diff --git a/test/@types/vitest.d.ts b/test/@types/vitest.d.ts index 7b756c45a57..04c017f8e73 100644 --- a/test/@types/vitest.d.ts +++ b/test/@types/vitest.d.ts @@ -1,23 +1,32 @@ import type { TerrainType } from "#app/data/terrain"; +import type { ArenaTag } 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 { toHaveEffectiveStatOptions } 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 type { PositionalTag } from "#data/positional-tags/positional-tag"; +import { PositionalTagType } from "#enums/positional-tag-type"; import type Overrides from "#app/overrides"; +import type { ArenaTagSide } from "#enums/arena-tag-side"; import type { PokemonMove } from "#moves/pokemon-move"; +import { toHaveArenaTagOptions } from "#test/test-utils/matchers/to-have-arena-tag"; +import { toHavePositionalTagOptions } from "#test/test-utils/matchers/to-have-positional-tag"; declare module "vitest" { - interface Assertion { + interface Assertion { /** * Check whether an array contains EXACTLY the given items (in any order). * @@ -27,45 +36,9 @@ declare module "vitest" { * @param expected - The expected contents of the array, in any order * @see {@linkcode expect.arrayContaining} */ - toEqualArrayUnsorted(expected: E[]): void; + toEqualArrayUnsorted(expected: T[]): void; - /** - * 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 - */ - 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, 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; + // #region Arena Matchers /** * Check whether the current {@linkcode WeatherType} is as expected. @@ -80,9 +53,63 @@ declare module "vitest" { toHaveTerrain(expectedTerrainType: TerrainType): void; /** - * Check whether a {@linkcode Pokemon} is at full HP. + * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}. + * @param expectedTag - A partially-filled {@linkcode ArenaTag} containing the desired properties */ - toHaveFullHp(): void; + toHaveArenaTag(expectedTag: toHaveArenaTagOptions): 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 the current {@linkcode Arena} contains the given {@linkcode PositionalTag}. + * @param expectedTag - A partially-filled {@linkcode PositionalTag} containing the desired properties + */ + toHavePositionalTag

(expectedTag: toHavePositionalTagOptions

): void; + /** + * Check whether the current {@linkcode Arena} contains the given number of {@linkcode PositionalTag}s. + * @param expectedType - The {@linkcode PositionalTagType} of the desired tag + * @param count - The number of instances of {@linkcode expectedType} that should be active; + * defaults to `1` and must be within the range `[0, 4]` + */ + toHavePositionalTag(expectedType: PositionalTagType, count?: number): void; + + // #endregion Arena Matchers + + // #region Pokemon Matchers + + /** + * Check whether a {@linkcode Pokemon}'s current typing includes the given types. + * @param expectedTags - The expected {@linkcode PokemonType}s to check against; must have length `>0` + * @param options - The {@linkcode toHaveTypesOptions | options} passed to the matcher + */ + toHaveTypes(expectedTags: PokemonType[], options?: toHaveTypesOptions): void; + + /** + * Check whether a {@linkcode Pokemon} has used a move matching the given criteria. + * @param expectedMove - 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) + * @see {@linkcode Pokemon.getLastXMoves} + */ + toHaveUsedMove(expectedMove: MoveId | AtLeastOne, 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 - The {@linkcode toHaveEffectiveStatOptions | options} passed to the matcher + * @remarks + * If you want to check the stat **before** modifiers are applied, use {@linkcode Pokemon.getStat} instead. + */ + toHaveEffectiveStat(stat: EffectiveStat, expectedValue: number, options?: toHaveEffectiveStatOptions): void; /** * Check whether a {@linkcode Pokemon} has a specific {@linkcode StatusEffect | non-volatile status effect}. @@ -106,7 +133,7 @@ declare module "vitest" { /** * Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}. - * @param expectedAbilityId - The expected {@linkcode AbilityId} + * @param expectedAbilityId - The expected {@linkcode AbilityId} to check for */ toHaveAbilityApplied(expectedAbilityId: AbilityId): void; @@ -116,24 +143,36 @@ declare module "vitest" { */ toHaveHp(expectedHp: number): 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 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`. + * When checking whether an enemy wild Pokemon is fainted, one must store a reference to it in a variable _before_ the fainting effect occurs, + * as otherwise the Pokemon will be removed from the field and garbage collected. */ toHaveFainted(): void; + /** + * Check whether a {@linkcode Pokemon} is at full HP. + */ + toHaveFullHp(): 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 moveId - 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. + * If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.OPP_MOVESET_OVERRIDE} + * or does not contain exactly 1 copy of {@linkcode moveId}, this will fail the test. */ - toHaveUsedPP(expectedMove: MoveId, ppUsed: number | "all"): void; + toHaveUsedPP(moveId: MoveId, ppUsed: number | "all"): void; + + // #region Pokemon Matchers } } diff --git a/test/matchers.setup.ts b/test/matchers.setup.ts index 03b29302916..f76a9423ab3 100644 --- a/test/matchers.setup.ts +++ b/test/matchers.setup.ts @@ -1,10 +1,12 @@ 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"; import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp"; import { toHaveHp } from "#test/test-utils/matchers/to-have-hp"; +import { toHavePositionalTag } from "#test/test-utils/matchers/to-have-positional-tag"; 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"; @@ -22,18 +24,20 @@ import { expect } from "vitest"; expect.extend({ toEqualArrayUnsorted, + toHaveWeather, + toHaveTerrain, + toHaveArenaTag, + toHavePositionalTag, toHaveTypes, toHaveUsedMove, toHaveEffectiveStat, - toHaveTakenDamage, - toHaveWeather, - toHaveTerrain, - toHaveFullHp, toHaveStatusEffect, toHaveStatStage, toHaveBattlerTag, toHaveAbilityApplied, toHaveHp, + toHaveTakenDamage, + toHaveFullHp, toHaveFainted, toHaveUsedPP, }); diff --git a/test/moves/wish.test.ts b/test/moves/wish.test.ts index 147c598106b..55877edbfd4 100644 --- a/test/moves/wish.test.ts +++ b/test/moves/wish.test.ts @@ -39,15 +39,6 @@ describe("Move - Wish", () => { .enemyLevel(100); }); - /** - * Expect that wish is active with the specified number of attacks. - * @param numAttacks - The number of wish instances that should be queued; default `1` - */ - function expectWishActive(numAttacks = 1) { - const wishes = game.scene.arena.positionalTagManager["tags"].filter(t => t.tagType === PositionalTagType.WISH); - expect(wishes).toHaveLength(numAttacks); - } - it("should heal the Pokemon in the current slot for 50% of the user's maximum HP", async () => { await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]); @@ -58,19 +49,19 @@ describe("Move - Wish", () => { game.move.use(MoveId.WISH); await game.toNextTurn(); - expectWishActive(); + expect(game).toHavePositionalTag(PositionalTagType.WISH); game.doSwitchPokemon(1); await game.toEndOfTurn(); - expectWishActive(0); + expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); expect(game.textInterceptor.logs).toContain( i18next.t("arenaTag:wishTagOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(alomomola), }), ); - expect(alomomola.hp).toBe(1); - expect(blissey.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1); + expect(alomomola).toHaveHp(1); + expect(blissey).toHaveHp(toDmgValue(alomomola.getMaxHp() / 2) + 1); }); it("should work if the user has full HP, but not if it already has an active Wish", async () => { @@ -82,13 +73,13 @@ describe("Move - Wish", () => { game.move.use(MoveId.WISH); await game.toNextTurn(); - expectWishActive(); + expect(game).toHavePositionalTag(PositionalTagType.WISH); game.move.use(MoveId.WISH); await game.toEndOfTurn(); expect(alomomola.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1); - expect(alomomola.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(alomomola).toHaveUsedMove({ result: MoveResult.FAIL }); }); it("should function independently of Future Sight", async () => { @@ -103,7 +94,8 @@ describe("Move - Wish", () => { await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); - expectWishActive(1); + expect(game).toHavePositionalTag(PositionalTagType.WISH); + expect(game).toHavePositionalTag(PositionalTagType.DELAYED_ATTACK); }); it("should work in double battles and trigger in order of creation", async () => { @@ -127,7 +119,7 @@ describe("Move - Wish", () => { await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex())); await game.toNextTurn(); - expectWishActive(4); + expect(game).toHavePositionalTag(PositionalTagType.WISH, 4); // Lower speed to change turn order alomomola.setStatStage(Stat.SPD, 6); @@ -141,7 +133,7 @@ describe("Move - Wish", () => { await game.phaseInterceptor.to("PositionalTagPhase"); // all wishes have activated and added healing phases - expectWishActive(0); + expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase")); expect(healPhases).toHaveLength(4); @@ -165,14 +157,14 @@ describe("Move - Wish", () => { game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2); await game.toNextTurn(); - expectWishActive(); + expect(game).toHavePositionalTag(PositionalTagType.WISH); game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); game.move.use(MoveId.MEMENTO, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); await game.toEndOfTurn(); // Wish went away without doing anything - expectWishActive(0); + expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); expect(game.textInterceptor.logs).not.toContain( i18next.t("arenaTag:wishTagOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(blissey), diff --git a/test/test-utils/matchers/to-equal-array-unsorted.ts b/test/test-utils/matchers/to-equal-array-unsorted.ts index 846ea9e7779..97398689032 100644 --- a/test/test-utils/matchers/to-equal-array-unsorted.ts +++ b/test/test-utils/matchers/to-equal-array-unsorted.ts @@ -1,4 +1,5 @@ import { getOnelineDiffStr } from "#test/test-utils/string-utils"; +import { receivedStr } from "#test/test-utils/test-utils"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; /** @@ -14,22 +15,22 @@ export function toEqualArrayUnsorted( ): SyncExpectationResult { if (!Array.isArray(received)) { return { - pass: false, - message: () => `Expected an array, but got ${this.utils.stringify(received)}!`, + pass: this.isNot, + message: () => `Expected to receive an array, but got ${receivedStr(received)}!`, }; } if (received.length !== expected.length) { return { pass: false, - message: () => `Expected to receive array of length ${received.length}, but got ${expected.length} instead!`, - actual: received, + message: () => `Expected to receive an array of length ${received.length}, but got ${expected.length} instead!`, expected, + actual: received, }; } - const actualSorted = received.slice().sort(); - const expectedSorted = expected.slice().sort(); + const actualSorted = received.toSorted(); + const expectedSorted = expected.toSorted(); const pass = this.equals(actualSorted, expectedSorted, [...this.customTesters, this.utils.iterableEquality]); const actualStr = getOnelineDiffStr.call(this, actualSorted); diff --git a/test/test-utils/matchers/to-have-ability-applied.ts b/test/test-utils/matchers/to-have-ability-applied.ts index a3921e6371c..8ef343c6d74 100644 --- a/test/test-utils/matchers/to-have-ability-applied.ts +++ b/test/test-utils/matchers/to-have-ability-applied.ts @@ -21,7 +21,7 @@ export function toHaveAbilityApplied( ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to recieve a Pokemon, but got ${receivedStr(received)}!`, }; } 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..2d03711fd46 --- /dev/null +++ b/test/test-utils/matchers/to-have-arena-tag.ts @@ -0,0 +1,79 @@ +import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag"; +import type { ArenaTagSide } from "#enums/arena-tag-side"; +import type { 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 { getOnelineDiffStr } 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"; + +// intersection required to preserve T for inferences +export type toHaveArenaTagOptions = OneOther & { + tagType: T; +}; + +/** + * 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 expectedTag - 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` + expectedTag: 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: this.isNot, + message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`, + }; + } + + if (typeof expectedTag === "string") { + // Coerce lone `tagType`s into objects + // Bangs are ok as we enforce safety via overloads + expectedTag = { tagType: expectedTag, 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 === expectedTag.tagType, expectedTag.side); + if (tags.length === 0) { + return { + pass: false, + message: () => `Expected the Arena to have a tag of type ${expectedTag.tagType}, but it didn't!`, + expected: expectedTag.tagType, + actual: 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, expectedTag, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]), + ); + + const expectedStr = getOnelineDiffStr.call(this, expectedTag); + 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: expectedTag, + actual: tags, + }; +} diff --git a/test/test-utils/matchers/to-have-effective-stat.ts b/test/test-utils/matchers/to-have-effective-stat.ts index bc10a646c02..dda6bc7e91e 100644 --- a/test/test-utils/matchers/to-have-effective-stat.ts +++ b/test/test-utils/matchers/to-have-effective-stat.ts @@ -6,7 +6,7 @@ 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 { +export interface toHaveEffectiveStatOptions { /** * The target {@linkcode Pokemon} * @see {@linkcode Pokemon.getEffectiveStat} @@ -30,7 +30,7 @@ export interface ToHaveEffectiveStatMatcherOptions { * @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} + * @param options - The {@linkcode toHaveEffectiveStatOptions} * @returns Whether the matcher passed */ export function toHaveEffectiveStat( @@ -38,11 +38,11 @@ export function toHaveEffectiveStat( received: unknown, stat: EffectiveStat, expectedValue: number, - { enemy, move, isCritical = false }: ToHaveEffectiveStatMatcherOptions = {}, + { enemy, move, isCritical = false }: toHaveEffectiveStatOptions = {}, ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } diff --git a/test/test-utils/matchers/to-have-fainted.ts b/test/test-utils/matchers/to-have-fainted.ts index 73ca96a31b5..f3e84e7a425 100644 --- a/test/test-utils/matchers/to-have-fainted.ts +++ b/test/test-utils/matchers/to-have-fainted.ts @@ -12,7 +12,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; export function toHaveFainted(this: MatcherState, received: unknown): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } diff --git a/test/test-utils/matchers/to-have-full-hp.ts b/test/test-utils/matchers/to-have-full-hp.ts index 3d7c8f9458d..893bb647283 100644 --- a/test/test-utils/matchers/to-have-full-hp.ts +++ b/test/test-utils/matchers/to-have-full-hp.ts @@ -12,7 +12,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; export function toHaveFullHp(this: MatcherState, received: unknown): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } diff --git a/test/test-utils/matchers/to-have-hp.ts b/test/test-utils/matchers/to-have-hp.ts index 20d171b23ce..e6463383ac2 100644 --- a/test/test-utils/matchers/to-have-hp.ts +++ b/test/test-utils/matchers/to-have-hp.ts @@ -13,7 +13,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; export function toHaveHp(this: MatcherState, received: unknown, expectedHp: number): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } diff --git a/test/test-utils/matchers/to-have-positional-tag.ts b/test/test-utils/matchers/to-have-positional-tag.ts new file mode 100644 index 00000000000..5ec9c71f3a1 --- /dev/null +++ b/test/test-utils/matchers/to-have-positional-tag.ts @@ -0,0 +1,108 @@ +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc +import type { GameManager } from "#test/test-utils/game-manager"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDoc + +import type { SerializedPositionalTag, serializedPosTagMap } from "#data/positional-tags/load-positional-tag"; +import type { PositionalTagType } from "#enums/positional-tag-type"; +import type { OneOther } from "#test/@types/test-helpers"; +import { getOnelineDiffStr } from "#test/test-utils/string-utils"; +import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils"; +import { toTitleCase } from "#utils/strings"; +import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; + +export type toHavePositionalTagOptions

= OneOther & { + tagType: P; +}; + +/** + * Matcher to check if the {@linkcode Arena} has a certain number of {@linkcode PositionalTag}s active. + * @param received - The object to check. Should be the current {@linkcode GameManager} + * @param expectedTag - The {@linkcode PositionalTagType} of the desired tag, or a partially-filled {@linkcode PositionalTag} + * containing the desired properties + * @param count - The number of tags that should be active; defaults to `1` and must be within the range `[0, 4]` + * @returns The result of the matching + */ +export function toHavePositionalTag

( + this: MatcherState, + received: unknown, + // simplified types used for brevity; full overloads are in `vitest.d.ts` + expectedTag: P | (Partial & { tagType: P }), + count = 1, +): SyncExpectationResult { + if (!isGameManagerInstance(received)) { + return { + pass: this.isNot, + message: () => `Expected to recieve a GameManager, but got ${receivedStr(received)}!`, + }; + } + + if (!received.scene?.arena?.positionalTagManager) { + return { + pass: this.isNot, + message: () => + `Expected GameManager.${received.scene?.arena ? "scene.arena.positionalTagManager" : received.scene ? "scene.arena" : "scene"} to be defined!`, + }; + } + + // TODO: Increase limit if triple battles are added + if (count < 0 || count > 4) { + return { + pass: this.isNot, + message: () => `Expected count to be between 0 and 4, but got ${count} instead!`, + }; + } + + const allTags = received.scene.arena.positionalTagManager.tags; + const tagType = typeof expectedTag === "string" ? expectedTag : expectedTag.tagType; + const matchingTags = allTags.filter(t => t.tagType === tagType); + + // If checking exclusively tag type, check solely the number of matching tags on field + if (typeof expectedTag === "string") { + const pass = matchingTags.length === count; + const expectedStr = getPosTagStr(expectedTag); + + return { + pass, + message: () => + pass + ? `Expected the Arena to NOT have ${count} ${expectedStr} active, but it did!` + : `Expected the Arena to have ${count} ${expectedStr} active, but got ${matchingTags.length} instead!`, + expected: expectedTag, + actual: allTags, + }; + } + + // Check for equality with the provided object + if (matchingTags.length === 0) { + return { + pass: false, + message: () => `Expected the Arena to have a tag of type ${expectedTag.tagType}, but it didn't!`, + expected: expectedTag.tagType, + actual: received.scene.arena.tags.map(t => t.tagType), + }; + } + + // Pass if any of the matching tags meet our criteria + const pass = matchingTags.some(tag => + this.equals(tag, expectedTag, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]), + ); + + const expectedStr = getOnelineDiffStr.call(this, expectedTag); + 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: expectedTag, + actual: matchingTags, + }; +} + +function getPosTagStr(pType: PositionalTagType, count = 1): string { + let ret = toTitleCase(pType) + "Tag"; + if (count > 1) { + ret += "s"; + } + return ret; +} diff --git a/test/test-utils/matchers/to-have-stat-stage.ts b/test/test-utils/matchers/to-have-stat-stage.ts index feecd650bef..a9ae910aece 100644 --- a/test/test-utils/matchers/to-have-stat-stage.ts +++ b/test/test-utils/matchers/to-have-stat-stage.ts @@ -23,14 +23,14 @@ export function toHaveStatStage( ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } if (expectedStage < -6 || expectedStage > 6) { return { - pass: false, + pass: this.isNot, message: () => `Expected ${expectedStage} to be within the range [-6, 6]!`, }; } diff --git a/test/test-utils/matchers/to-have-status-effect.ts b/test/test-utils/matchers/to-have-status-effect.ts index a46800632f3..fa5f0346ebd 100644 --- a/test/test-utils/matchers/to-have-status-effect.ts +++ b/test/test-utils/matchers/to-have-status-effect.ts @@ -28,7 +28,7 @@ export function toHaveStatusEffect( ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } @@ -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") { diff --git a/test/test-utils/matchers/to-have-taken-damage.ts b/test/test-utils/matchers/to-have-taken-damage.ts index 77c60ae836a..55c163a2dc7 100644 --- a/test/test-utils/matchers/to-have-taken-damage.ts +++ b/test/test-utils/matchers/to-have-taken-damage.ts @@ -24,7 +24,7 @@ export function toHaveTakenDamage( ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } diff --git a/test/test-utils/matchers/to-have-terrain.ts b/test/test-utils/matchers/to-have-terrain.ts index 292c32abafc..f951abed0b3 100644 --- a/test/test-utils/matchers/to-have-terrain.ts +++ b/test/test-utils/matchers/to-have-terrain.ts @@ -20,15 +20,15 @@ export function toHaveTerrain( ): SyncExpectationResult { if (!isGameManagerInstance(received)) { return { - pass: false, - message: () => `Expected GameManager, but got ${receivedStr(received)}!`, + pass: this.isNot, + message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`, }; } if (!received.scene?.arena) { return { - pass: false, - message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`, + pass: this.isNot, + message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`, }; } @@ -41,8 +41,8 @@ export function toHaveTerrain( pass, message: () => pass - ? `Expected Arena to NOT have ${expectedStr} active, but it did!` - : `Expected Arena to have ${expectedStr} active, but got ${actualStr} instead!`, + ? `Expected the Arena to NOT have ${expectedStr} active, but it did!` + : `Expected the Arena to have ${expectedStr} active, but got ${actualStr} instead!`, expected: expectedTerrainType, actual, }; diff --git a/test/test-utils/matchers/to-have-types.ts b/test/test-utils/matchers/to-have-types.ts index 3f16f740583..ce72571ee2f 100644 --- a/test/test-utils/matchers/to-have-types.ts +++ b/test/test-utils/matchers/to-have-types.ts @@ -7,10 +7,16 @@ import { isPokemonInstance, receivedStr } from "../test-utils"; export interface toHaveTypesOptions { /** - * Whether to enforce exact matches (`true`) or superset matches (`false`). - * @defaultValue `true` + * Value dictating the strength of the enforced typing match. + * + * Possible values (in ascending order of strength) are: + * - `"ordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **and in the same order** + * - `"unordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **without checking order** + * - `"superset"`: Enforce that the {@linkcode Pokemon}'s types are **a superset of** the expected types + * (all must be present, but extras can be there) + * @defaultValue `"unordered"` */ - exact?: boolean; + mode?: "ordered" | "unordered" | "superset"; /** * Optional arguments to pass to {@linkcode Pokemon.getTypes}. */ @@ -18,35 +24,46 @@ export interface toHaveTypesOptions { } /** - * 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 + * Matcher that checks if a {@linkcode Pokemon}'s typing is as expected. + * @param received - The object to check. Should be a {@linkcode Pokemon} + * @param expectedTypes - An array of one or more {@linkcode PokemonType}s to compare against. + * @param mode - The mode to perform the matching; * @returns The result of the matching */ export function toHaveTypes( this: MatcherState, received: unknown, - expected: [PokemonType, ...PokemonType[]], - options: toHaveTypesOptions = {}, + expectedTypes: [PokemonType, ...PokemonType[]], + { mode = "unordered", args = [] }: toHaveTypesOptions = {}, ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to recieve a Pokémon, but got ${receivedStr(received)}!`, }; } - const actualTypes = received.getTypes(...(options.args ?? [])).sort(); - const expectedTypes = expected.slice().sort(); + // Return early if no types were passed in + if (expectedTypes.length === 0) { + return { + pass: this.isNot, + message: () => "Expected to receive a non-empty array of PokemonTypes!", + }; + } + + // Avoid sorting the types if strict ordering is desired + const actualSorted = mode === "ordered" ? received.getTypes(...args) : received.getTypes(...args).toSorted(); + const expectedSorted = mode === "ordered" ? expectedTypes : expectedTypes.toSorted(); // 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 matchers = + mode === "superset" + ? [...this.customTesters, this.utils.iterableEquality] + : [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]; + const pass = this.equals(actualSorted, expectedSorted, matchers); - const actualStr = stringifyEnumArray(PokemonType, actualTypes); - const expectedStr = stringifyEnumArray(PokemonType, expectedTypes); + const actualStr = stringifyEnumArray(PokemonType, actualSorted); + const expectedStr = stringifyEnumArray(PokemonType, expectedSorted); const pkmName = getPokemonNameWithAffix(received); return { @@ -55,7 +72,7 @@ export function toHaveTypes( 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, + expected: expectedSorted, + actual: actualSorted, }; } diff --git a/test/test-utils/matchers/to-have-used-move.ts b/test/test-utils/matchers/to-have-used-move.ts index ef90e4dbad9..3697b3e0bc6 100644 --- a/test/test-utils/matchers/to-have-used-move.ts +++ b/test/test-utils/matchers/to-have-used-move.ts @@ -13,7 +13,7 @@ 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, + * @param expectedMove - 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) @@ -22,12 +22,12 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; export function toHaveUsedMove( this: MatcherState, received: unknown, - expectedResult: MoveId | AtLeastOne, + expectedMove: MoveId | AtLeastOne, index = 0, ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } @@ -37,34 +37,33 @@ export function toHaveUsedMove( if (move === undefined) { return { - pass: false, + pass: this.isNot, 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 }; + if (typeof expectedMove === "number") { + expectedMove = { move: expectedMove }; } const moveIndexStr = index === 0 ? "last move" : `${getOrdinal(index)} most recent move`; - const pass = this.equals(move, expectedResult, [ + const pass = this.equals(move, expectedMove, [ ...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality, ]); - const expectedStr = getOnelineDiffStr.call(this, expectedResult); + const expectedStr = getOnelineDiffStr.call(this, expectedMove); 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, + : `Expected ${pkmName}'s ${moveIndexStr} to match ${expectedStr}, but it didn't!`, + expected: expectedMove, actual: move, }; } diff --git a/test/test-utils/matchers/to-have-used-pp.ts b/test/test-utils/matchers/to-have-used-pp.ts index 3b606a535bc..269d97c6ee2 100644 --- a/test/test-utils/matchers/to-have-used-pp.ts +++ b/test/test-utils/matchers/to-have-used-pp.ts @@ -13,7 +13,7 @@ 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 moveId - 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 @@ -23,12 +23,12 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; export function toHaveUsedPP( this: MatcherState, received: unknown, - expectedMove: MoveId, + moveId: MoveId, ppUsed: number | "all", ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } @@ -36,22 +36,22 @@ export function toHaveUsedPP( const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.OPP_MOVESET_OVERRIDE; if (coerceArray(override).length > 0) { return { - pass: false, + pass: this.isNot, message: () => `Cannot test for PP consumption with ${received.isPlayer() ? "player" : "enemy"} moveset overrides active!`, }; } const pkmName = getPokemonNameWithAffix(received); - const moveStr = getEnumStr(MoveId, expectedMove); + const moveStr = getEnumStr(MoveId, moveId); - const movesetMoves = received.getMoveset().filter(pm => pm.moveId === expectedMove); + const movesetMoves = received.getMoveset().filter(pm => pm.moveId === moveId); if (movesetMoves.length !== 1) { return { - pass: false, + pass: this.isNot, message: () => `Expected MoveId.${moveStr} to appear in ${pkmName}'s moveset exactly once, but got ${movesetMoves.length} times!`, - expected: expectedMove, + expected: moveId, actual: received.getMoveset(), }; } diff --git a/test/test-utils/matchers/to-have-weather.ts b/test/test-utils/matchers/to-have-weather.ts index 49433b2137b..ffb1e0aad97 100644 --- a/test/test-utils/matchers/to-have-weather.ts +++ b/test/test-utils/matchers/to-have-weather.ts @@ -20,15 +20,15 @@ export function toHaveWeather( ): SyncExpectationResult { if (!isGameManagerInstance(received)) { return { - pass: false, - message: () => `Expected GameManager, but got ${receivedStr(received)}!`, + pass: this.isNot, + message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`, }; } if (!received.scene?.arena) { return { - pass: false, - message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`, + pass: this.isNot, + message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`, }; } @@ -41,8 +41,8 @@ export function toHaveWeather( 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 the Arena to NOT have ${expectedStr} weather active, but it did!` + : `Expected the Arena to have ${expectedStr} weather active, but got ${actualStr} instead!`, expected: expectedWeatherType, actual, }; diff --git a/test/test-utils/string-utils.ts b/test/test-utils/string-utils.ts index bd3dd7c2fa9..6c29c04c107 100644 --- a/test/test-utils/string-utils.ts +++ b/test/test-utils/string-utils.ts @@ -34,10 +34,10 @@ interface getEnumStrOptions { * @returns The stringified representation of `val` as dictated by the options. * @example * ```ts - * enum fakeEnum { - * ONE: 1, - * TWO: 2, - * THREE: 3, + * enum testEnum { + * 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)" @@ -174,10 +174,14 @@ export function getStatName(s: Stat): string { * 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 + * @example + * ```ts + * const diff = getOnelineDiffStr.call(this, obj) + * ``` */ 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}"); + .replace(/,(\s*)}$/g, "$1}"); // Trim trailing commas }