[Test] Improved typing on BattlerTagData and ArenaTagData; improved test matchers (#6338)

* Improved typing on `BattlerTagData` and `ArenaTagData`
* Added dragon cheer/focus energy tests
* Cleaned up descs
This commit is contained in:
Bertie690 2025-09-05 22:50:37 -04:00 committed by GitHub
parent 02bfaf9ad3
commit d630c106e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 270 additions and 103 deletions

View File

@ -2,6 +2,7 @@ import type { ArenaTagTypeMap } from "#data/arena-tag";
import type { ArenaTagType } from "#enums/arena-tag-type";
// biome-ignore lint/correctness/noUnusedImports: TSDocs
import type { SessionSaveData } from "#system/game-data";
import type { ObjectValues } from "#types/type-helpers";
/** Subset of {@linkcode ArenaTagType}s that apply some negative effect to pokemon that switch in ({@link https://bulbapedia.bulbagarden.net/wiki/List_of_moves_that_cause_entry_hazards#List_of_traps | entry hazards} and Imprison. */
export type EntryHazardTagType =
@ -24,22 +25,32 @@ export type TurnProtectArenaTagType =
/** Subset of {@linkcode ArenaTagType}s that create Trick Room-like effects which are removed upon overlap. */
export type RoomArenaTagType = ArenaTagType.TRICK_ROOM;
/** Subset of {@linkcode ArenaTagType}s that cannot persist across turns, and thus should not be serialized in {@linkcode SessionSaveData}. */
/** Subset of {@linkcode ArenaTagType}s that are **not** able to persist across turns, and should therefore not be serialized in {@linkcode SessionSaveData}. */
export type NonSerializableArenaTagType = ArenaTagType.NONE | TurnProtectArenaTagType | ArenaTagType.ION_DELUGE;
/** Subset of {@linkcode ArenaTagType}s that may persist across turns, and thus must be serialized in {@linkcode SessionSaveData}. */
export type SerializableArenaTagType = Exclude<ArenaTagType, NonSerializableArenaTagType>;
/**
* Type-safe representation of an arbitrary, serialized Arena Tag
* Utility type containing all entries of {@linkcode ArenaTagTypeMap} corresponding to serializable tags.
*/
export type ArenaTagTypeData = Parameters<
ArenaTagTypeMap[keyof {
[K in keyof ArenaTagTypeMap as K extends SerializableArenaTagType ? K : never]: ArenaTagTypeMap[K];
}]["loadTag"]
>[0];
type SerializableArenaTagTypeMap = Pick<ArenaTagTypeMap, SerializableArenaTagType>;
/** Dummy, typescript-only declaration to ensure that
/**
* Type mapping all `ArenaTag`s to type-safe representations of their serialized forms.
* @interface
*/
export type ArenaTagDataMap = {
[k in keyof SerializableArenaTagTypeMap]: Parameters<SerializableArenaTagTypeMap[k]["loadTag"]>[0];
};
/**
* Type-safe representation of an arbitrary, serialized `ArenaTag`.
*/
export type ArenaTagData = ObjectValues<ArenaTagDataMap>;
/**
* Dummy, typescript-only declaration to ensure that
* {@linkcode ArenaTagTypeMap} has a map for all ArenaTagTypes.
*
* If an arena tag is missing from the map, typescript will throw an error on this statement.

View File

@ -1,8 +1,11 @@
// biome-ignore-start lint/correctness/noUnusedImports: Used in a TSDoc comment
import type { AbilityBattlerTag, BattlerTagTypeMap, SerializableBattlerTag, TypeBoostTag } from "#data/battler-tags";
import type { AbilityId } from "#enums/ability-id";
// biome-ignore-end lint/correctness/noUnusedImports: end
import type { SessionSaveData } from "#system/game-data";
// biome-ignore-end lint/correctness/noUnusedImports: Used in a TSDoc comment
import type { BattlerTagType } from "#enums/battler-tag-type";
import type { InferKeys, ObjectValues } from "#types/type-helpers";
/**
* Subset of {@linkcode BattlerTagType}s that restrict the use of moves.
@ -103,28 +106,35 @@ export type RemovedTypeTagType = BattlerTagType.DOUBLE_SHOCKED | BattlerTagType.
export type HighestStatBoostTagType =
| BattlerTagType.QUARK_DRIVE // formatting
| BattlerTagType.PROTOSYNTHESIS;
/**
* Subset of {@linkcode BattlerTagType}s that are able to persist between turns and should therefore be serialized
*/
export type SerializableBattlerTagType = keyof {
[K in keyof BattlerTagTypeMap as BattlerTagTypeMap[K] extends SerializableBattlerTag
? K
: never]: BattlerTagTypeMap[K];
};
/**
* Subset of {@linkcode BattlerTagType}s that are not able to persist across waves and should therefore not be serialized
* Subset of {@linkcode BattlerTagType}s that are able to persist between turns, and should therefore be serialized.
*/
export type SerializableBattlerTagType = InferKeys<BattlerTagTypeMap, SerializableBattlerTag>;
/**
* Subset of {@linkcode BattlerTagType}s that are **not** able to persist between turns,
* and should therefore not be serialized in {@linkcode SessionSaveData}.
*/
export type NonSerializableBattlerTagType = Exclude<BattlerTagType, SerializableBattlerTagType>;
/**
* Type-safe representation of an arbitrary, serialized Battler Tag
* Utility type containing all entries of {@linkcode BattlerTagTypeMap} corresponding to serializable tags.
*/
export type BattlerTagTypeData = Parameters<
BattlerTagTypeMap[keyof {
[K in keyof BattlerTagTypeMap as K extends SerializableBattlerTagType ? K : never]: BattlerTagTypeMap[K];
}]["loadTag"]
>[0];
type SerializableBattlerTagTypeMap = Pick<BattlerTagTypeMap, SerializableBattlerTagType>;
/**
* Type mapping all `BattlerTag`s to type-safe representations of their serialized forms.
* @interface
*/
export type BattlerTagDataMap = {
[k in keyof SerializableBattlerTagTypeMap]: Parameters<SerializableBattlerTagTypeMap[k]["loadTag"]>[0];
};
/**
* Type-safe representation of an arbitrary, serialized `BattlerTag`.
*/
export type BattlerTagData = ObjectValues<BattlerTagDataMap>;
/**
* Dummy, typescript-only declaration to ensure that

View File

@ -36,15 +36,18 @@ export type Mutable<T> = {
/**
* Type helper to obtain the keys associated with a given value inside an object.
* Acts similar to {@linkcode Pick}, except checking the object's values instead of its keys.
* @typeParam O - The type of the object
* @typeParam V - The type of one of O's values
* @typeParam V - The type of one of O's values.
*/
export type InferKeys<O extends object, V extends ObjectValues<O>> = {
export type InferKeys<O extends object, V> = V extends ObjectValues<O>
? {
[K in keyof O]: O[K] extends V ? K : never;
}[keyof O];
}[keyof O]
: never;
/**
* Utility type to obtain the values of a given object. \
* Utility type to obtain a union of the values of a given object. \
* Functions similar to `keyof E`, except producing the values instead of the keys.
* @remarks
* This can be used to convert an `enum` interface produced by `typeof Enum` into the union type representing its members.

View File

@ -23,7 +23,7 @@ import type { Arena } from "#field/arena";
import type { Pokemon } from "#field/pokemon";
import type {
ArenaScreenTagType,
ArenaTagTypeData,
ArenaTagData,
EntryHazardTagType,
RoomArenaTagType,
SerializableArenaTagType,
@ -1663,7 +1663,7 @@ export function getArenaTag(
* @param source - An arena tag
* @returns The valid arena tag
*/
export function loadArenaTag(source: ArenaTag | ArenaTagTypeData | { tagType: ArenaTagType.NONE }): ArenaTag {
export function loadArenaTag(source: ArenaTag | ArenaTagData | { tagType: ArenaTagType.NONE }): ArenaTag {
if (source.tagType === ArenaTagType.NONE) {
return new NoneTag();
}

View File

@ -34,7 +34,7 @@ import type { StatStageChangeCallback } from "#phases/stat-stage-change-phase";
import i18next from "#plugins/i18n";
import type {
AbilityBattlerTagType,
BattlerTagTypeData,
BattlerTagData,
ContactSetStatusProtectedTagType,
ContactStatStageChangeProtectedTagType,
CritStageBoostTagType,
@ -3843,7 +3843,7 @@ export function getBattlerTag(
* @param source - An object containing the data necessary to reconstruct the BattlerTag.
* @returns The valid battler tag
*/
export function loadBattlerTag(source: BattlerTag | BattlerTagTypeData): BattlerTag {
export function loadBattlerTag(source: BattlerTag | BattlerTagData): BattlerTag {
// TODO: Remove this bang by fixing the signature of `getBattlerTag`
// to allow undefined sourceIds and sourceMoves (with appropriate fallback for tags that require it)
const tag = getBattlerTag(source.tagType, source.turnCount, source.sourceMove!, source.sourceId!);

View File

@ -8903,7 +8903,9 @@ export function initMoves() {
.attr(AddArenaTagAttr, ArenaTagType.REFLECT, 5, true)
.target(MoveTarget.USER_SIDE),
new SelfStatusMove(MoveId.FOCUS_ENERGY, PokemonType.NORMAL, -1, 30, -1, 0, 1)
.attr(AddBattlerTagAttr, BattlerTagType.CRIT_BOOST, true, true),
.attr(AddBattlerTagAttr, BattlerTagType.CRIT_BOOST, true, true)
// TODO: Remove once dragon cheer & focus energy are merged into 1 tag
.condition((_user, target) => !target.getTag(BattlerTagType.DRAGON_CHEER)),
new AttackMove(MoveId.BIDE, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, -1, 10, -1, 1, 1)
.target(MoveTarget.USER)
.unimplemented(),
@ -11601,6 +11603,8 @@ export function initMoves() {
.attr(OpponentHighHpPowerAttr, 100),
new StatusMove(MoveId.DRAGON_CHEER, PokemonType.DRAGON, -1, 15, -1, 0, 9)
.attr(AddBattlerTagAttr, BattlerTagType.DRAGON_CHEER, false, true)
// TODO: Remove once dragon cheer & focus energy are merged into 1 tag
.condition((_user, target) => !target.getTag(BattlerTagType.CRIT_BOOST))
.target(MoveTarget.NEAR_ALLY),
new AttackMove(MoveId.ALLURING_VOICE, PokemonType.FAIRY, MoveCategory.SPECIAL, 80, 100, 10, 100, 0, 9)
.attr(AddBattlerTagIfBoostedAttr, BattlerTagType.CONFUSED)

View File

@ -5,14 +5,14 @@ import { Terrain } from "#data/terrain";
import { Weather } from "#data/weather";
import type { BiomeId } from "#enums/biome-id";
import { Arena } from "#field/arena";
import type { ArenaTagTypeData } from "#types/arena-tags";
import type { ArenaTagData } from "#types/arena-tags";
import type { NonFunctionProperties } from "#types/type-helpers";
export interface SerializedArenaData {
biome: BiomeId;
weather: NonFunctionProperties<Weather> | null;
terrain: NonFunctionProperties<Terrain> | null;
tags?: ArenaTagTypeData[];
tags?: ArenaTagData[];
positionalTags: SerializedPositionalTag[];
playerTerasUsed?: number;
}
@ -31,7 +31,7 @@ export class ArenaData {
// is not yet an instance of `ArenaTag`
this.tags =
source.tags
?.map((t: ArenaTag | ArenaTagTypeData) => loadArenaTag(t))
?.map((t: ArenaTag | ArenaTagData) => loadArenaTag(t))
?.filter((tag): tag is SerializableArenaTag => tag instanceof SerializableArenaTag) ?? [];
this.playerTerasUsed = source.playerTerasUsed ?? 0;

View File

@ -4,6 +4,7 @@ import type { Phase } from "#app/phase";
import type Overrides from "#app/overrides";
import type { ArenaTag } from "#data/arena-tag";
import type { TerrainType } from "#data/terrain";
import type { BattlerTag } from "#data/battler-tags";
import type { PositionalTag } from "#data/positional-tags/positional-tag";
import type { AbilityId } from "#enums/ability-id";
import type { ArenaTagSide } from "#enums/arena-tag-side";
@ -28,6 +29,7 @@ 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 { toHaveBattlerTagOptions } from "#test/test-utils/matchers/to-have-battler-tag";
declare module "vitest" {
interface Assertion<T> {
@ -133,10 +135,15 @@ declare module "vitest" {
toHaveStatStage(stat: BattleStat, expectedStage: number): void;
/**
* Check whether a {@linkcode Pokemon} has a specific {@linkcode BattlerTagType}.
* @param expectedBattlerTagType - The expected {@linkcode BattlerTagType}
* Check whether a {@linkcode Pokemon} has the given {@linkcode BattlerTag}.
* @param expectedTag - A partially-filled {@linkcode BattlerTag} containing the desired properties
*/
toHaveBattlerTag(expectedBattlerTagType: BattlerTagType): void;
toHaveBattlerTag<B extends BattlerTagType>(expectedTag: toHaveBattlerTagOptions<B>): void;
/**
* Check whether a {@linkcode Pokemon} has the given {@linkcode BattlerTag}.
* @param expectedType - The expected {@linkcode BattlerTagType}
*/
toHaveBattlerTag(expectedType: BattlerTagType): void;
/**
* Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}.

View File

@ -1,15 +1,18 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Dragon Cheer", () => {
describe("Move - Dragon Cheer", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
@ -24,75 +27,81 @@ describe("Moves - Dragon Cheer", () => {
game = new GameManager(phaserGame);
game.override
.battleStyle("double")
.ability(AbilityId.BALL_FETCH)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH)
.enemyLevel(20)
.moveset([MoveId.DRAGON_CHEER, MoveId.TACKLE, MoveId.SPLASH]);
.enemyLevel(20);
});
it("increases the user's allies' critical hit ratio by one stage", async () => {
it("should increase non-Dragon type allies' crit ratios by 1 stage", async () => {
await game.classicMode.startBattle([SpeciesId.DRAGONAIR, SpeciesId.MAGIKARP]);
const enemy = game.scene.getEnemyField()[0];
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getCritStage");
game.move.select(MoveId.DRAGON_CHEER, 0);
game.move.select(MoveId.TACKLE, 1, BattlerIndex.ENEMY);
game.move.use(MoveId.DRAGON_CHEER, BattlerIndex.PLAYER);
game.move.use(MoveId.TACKLE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.toEndOfTurn();
// After Tackle
await game.phaseInterceptor.to("TurnEndPhase");
const [dragonair, magikarp] = game.scene.getPlayerField();
expect(dragonair).not.toHaveBattlerTag(BattlerTagType.DRAGON_CHEER);
expect(magikarp).toHaveBattlerTag({ tagType: BattlerTagType.DRAGON_CHEER, critStages: 1 });
expect(enemy.getCritStage).toHaveReturnedWith(1); // getCritStage is called on defender
});
it("increases the user's Dragon-type allies' critical hit ratio by two stages", async () => {
it("should increase Dragon-type allies' crit ratios by 2 stages", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.DRAGONAIR]);
const enemy = game.scene.getEnemyField()[0];
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getCritStage");
game.move.select(MoveId.DRAGON_CHEER, 0);
game.move.select(MoveId.TACKLE, 1, BattlerIndex.ENEMY);
game.move.use(MoveId.DRAGON_CHEER, BattlerIndex.PLAYER);
game.move.use(MoveId.TACKLE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.toEndOfTurn();
// After Tackle
await game.phaseInterceptor.to("TurnEndPhase");
const [magikarp, dragonair] = game.scene.getPlayerField();
expect(magikarp).not.toHaveBattlerTag(BattlerTagType.DRAGON_CHEER);
expect(dragonair).toHaveBattlerTag({ tagType: BattlerTagType.DRAGON_CHEER, critStages: 2 });
expect(enemy.getCritStage).toHaveReturnedWith(2); // getCritStage is called on defender
});
it("applies the effect based on the allies' type upon use of the move, and do not change if the allies' type changes later in battle", async () => {
it("should maintain crit boost amount even if user's type is changed", async () => {
await game.classicMode.startBattle([SpeciesId.DRAGONAIR, SpeciesId.MAGIKARP]);
const magikarp = game.scene.getPlayerField()[1];
const enemy = game.scene.getEnemyField()[0];
vi.spyOn(enemy, "getCritStage");
game.move.select(MoveId.DRAGON_CHEER, 0);
game.move.select(MoveId.TACKLE, 1, BattlerIndex.ENEMY);
// Use Reflect Type to become Dragon-type mid-turn
game.move.use(MoveId.DRAGON_CHEER, BattlerIndex.PLAYER);
game.move.use(MoveId.REFLECT_TYPE, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
// After Tackle
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.getCritStage).toHaveReturnedWith(1); // getCritStage is called on defender
await game.toNextTurn();
// Change Magikarp's type to Dragon
vi.spyOn(magikarp, "getTypes").mockReturnValue([PokemonType.DRAGON]);
expect(magikarp.getTypes()).toEqual([PokemonType.DRAGON]);
game.move.select(MoveId.SPLASH, 0);
game.move.select(MoveId.TACKLE, 1, BattlerIndex.ENEMY);
await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("MoveEndPhase");
expect(enemy.getCritStage).toHaveReturnedWith(1); // getCritStage is called on defender
// Dragon cheer added +1 stages
const magikarp = game.scene.getPlayerField()[1];
expect(magikarp).toHaveBattlerTag({ tagType: BattlerTagType.DRAGON_CHEER, critStages: 1 });
expect(magikarp).toHaveTypes([PokemonType.WATER]);
await game.toEndOfTurn();
// Should be dragon type, but still with a +1 stage boost
expect(magikarp).toHaveTypes([PokemonType.DRAGON]);
expect(magikarp).toHaveBattlerTag({ tagType: BattlerTagType.DRAGON_CHEER, critStages: 1 });
});
it.each([
{ name: "Focus Energy", tagType: BattlerTagType.CRIT_BOOST },
{ name: "Dragon Cheer", tagType: BattlerTagType.DRAGON_CHEER },
])("should fail if $name is already present", async ({ tagType }) => {
await game.classicMode.startBattle([SpeciesId.DRAGONAIR, SpeciesId.MAGIKARP]);
const [dragonair, magikarp] = game.scene.getPlayerField();
magikarp.addTag(tagType);
game.move.use(MoveId.DRAGON_CHEER, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
expect(dragonair).toHaveUsedMove({ move: MoveId.DRAGON_CHEER, result: MoveResult.FAIL });
expect(magikarp).toHaveBattlerTag(tagType);
});
});

View File

@ -0,0 +1,69 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Move - Focus Energy", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH)
.startingLevel(100)
.enemyLevel(100);
});
it("should increase the user's crit ratio by 2 stages", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.use(MoveId.FOCUS_ENERGY);
await game.toNextTurn();
const feebas = game.field.getPlayerPokemon();
expect(feebas).toHaveBattlerTag({ tagType: BattlerTagType.CRIT_BOOST, critStages: 2 });
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getCritStage");
game.move.use(MoveId.TACKLE);
await game.toEndOfTurn();
expect(enemy.getCritStage).toHaveReturnedWith(2);
});
it.each([
{ name: "Focus Energy", tagType: BattlerTagType.CRIT_BOOST },
{ name: "Dragon Cheer", tagType: BattlerTagType.DRAGON_CHEER },
])("should fail if $name is already present", async ({ tagType }) => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const feebas = game.field.getPlayerPokemon();
feebas.addTag(tagType);
game.move.use(MoveId.FOCUS_ENERGY);
await game.toEndOfTurn();
expect(feebas).toHaveUsedMove({ move: MoveId.FOCUS_ENERGY, result: MoveResult.FAIL });
});
});

View File

@ -6,11 +6,21 @@ import type { OneOther } from "#test/@types/test-helpers";
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 { ArenaTagDataMap, SerializableArenaTagType } from "#types/arena-tags";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
// intersection required to preserve T for inferences
export type toHaveArenaTagOptions<T extends ArenaTagType> = OneOther<ArenaTagTypeMap[T], "tagType" | "side"> & {
tagType: T;
/**
* Options type for {@linkcode toHaveArenaTag}.
* @typeParam A - The {@linkcode ArenaTagType} being checked
* @remarks
* If A corresponds to a serializable `ArenaTag`, only properties allowed to be serialized
* (i.e. can change across instances) will be present and able to be checked.
*/
export type toHaveArenaTagOptions<A extends ArenaTagType> = OneOther<
A extends SerializableArenaTagType ? ArenaTagDataMap[A] : ArenaTagTypeMap[A],
"tagType" | "side"
> & {
tagType: A;
};
/**
@ -22,10 +32,10 @@ export type toHaveArenaTagOptions<T extends ArenaTagType> = OneOther<ArenaTagTyp
* {@linkcode ArenaTagSide.BOTH} to check both sides
* @returns The result of the matching
*/
export function toHaveArenaTag<T extends ArenaTagType>(
export function toHaveArenaTag<A extends ArenaTagType>(
this: MatcherState,
received: unknown,
expectedTag: T | toHaveArenaTagOptions<T>,
expectedTag: A | toHaveArenaTagOptions<A>,
side: ArenaTagSide = ArenaTagSide.BOTH,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {

View File

@ -3,21 +3,39 @@ import type { Pokemon } from "#field/pokemon";
/* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import type { BattlerTagTypeMap } from "#data/battler-tags";
import { BattlerTagType } from "#enums/battler-tag-type";
import { getEnumStr } from "#test/test-utils/string-utils";
import type { OneOther } from "#test/@types/test-helpers";
import { getEnumStr, getOnelineDiffStr } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { BattlerTagDataMap, SerializableBattlerTagType } from "#types/battler-tags";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
// intersection required to preserve T for inferences
/**
* Matcher that checks if a {@linkcode Pokemon} has a specific {@linkcode BattlerTagType}.
* Options type for {@linkcode toHaveBattlerTag}.
* @typeParam B - The {@linkcode BattlerTagType} being checked
* @remarks
* If B corresponds to a serializable `BattlerTag`, only properties allowed to be serialized
* (i.e. can change across instances) will be present and able to be checked.
*/
export type toHaveBattlerTagOptions<B extends BattlerTagType> = (B extends SerializableBattlerTagType
? OneOther<BattlerTagDataMap[B], "tagType">
: OneOther<BattlerTagTypeMap[B], "tagType">) & {
tagType: B;
};
/**
* Matcher that checks if a {@linkcode Pokemon} has a specific {@linkcode BattlerTag}.
* @param received - The object to check. Should be a {@linkcode Pokemon}
* @param expectedBattlerTagType - The {@linkcode BattlerTagType} to check for
* @param expectedTag - The `BattlerTagType` of the desired tag, or a partially-filled object
* containing the desired properties
* @returns Whether the matcher passed
*/
export function toHaveBattlerTag(
export function toHaveBattlerTag<B extends BattlerTagType>(
this: MatcherState,
received: unknown,
expectedBattlerTagType: BattlerTagType,
expectedTag: B | toHaveBattlerTagOptions<B>,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
@ -26,18 +44,44 @@ export function toHaveBattlerTag(
};
}
const pass = !!received.getTag(expectedBattlerTagType);
const pkmName = getPokemonNameWithAffix(received);
// Coerce lone `tagType`s into objects
const etag = typeof expectedTag === "object" ? expectedTag : { tagType: expectedTag };
const gotTag = received.getTag(etag.tagType);
// If checking exclusively tag type OR no tags were found, break out early.
if (typeof expectedTag !== "object" || !gotTag) {
const pass = !!gotTag;
// "BattlerTagType.SEEDED (=1)"
const expectedTagStr = getEnumStr(BattlerTagType, expectedBattlerTagType, { prefix: "BattlerTagType." });
const expectedTagStr = getEnumStr(BattlerTagType, etag.tagType, { prefix: "BattlerTagType." });
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have ${expectedTagStr}, but it did!`
: `Expected ${pkmName} to have ${expectedTagStr}, but it didn't!`,
expected: expectedBattlerTagType,
? `Expected ${pkmName} to NOT have a tag of type ${expectedTagStr}, but it did!`
: `Expected ${pkmName} to have a tag of type ${expectedTagStr}, but it didn't!`,
expected: expectedTag,
actual: received.summonData.tags.map(t => t.tagType),
};
}
// Check for equality with the provided tag
const pass = this.equals(gotTag, etag, [
...this.customTesters,
this.utils.subsetEquality,
this.utils.iterableEquality,
]);
const expectedStr = getOnelineDiffStr.call(this, expectedTag);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have a tag matching ${expectedStr}, but it did!`
: `Expected ${pkmName} to have a tag matching ${expectedStr}, but it didn't!`,
expected: expectedTag,
actual: gotTag,
};
}

View File

@ -183,5 +183,5 @@ 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}"); // Trim trailing commas
.replace(/,(\s*)\}$/g, "$1}"); // Trim trailing commas
}