Added test utils for held item tests

This commit is contained in:
Bertie690 2025-08-07 17:38:01 -04:00
parent a8e4f76a4f
commit 6a2c92dad7
14 changed files with 254 additions and 23 deletions

View File

@ -1,8 +1,8 @@
import { AbilityId } from "#enums/ability-id";
import { getHeldItemCategory, HeldItemCategoryId } from "#enums/held-item-id";
import { MoveId } from "#enums/move-id";
import { RewardId } from "#enums/reward-id";
import { SpeciesId } from "#enums/species-id";
import { BerryHeldItem } from "#items/berry";
import { HeldItemReward } from "#items/reward";
import { GameManager } from "#test/test-utils/game-manager";
import { generateRewardForTest } from "#test/test-utils/reward-test-utils";
@ -39,9 +39,13 @@ describe("{{description}}", () => {
it("should do XYZ when applied", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const reward = generateRewardForTest(RewardId.BERRY);
expect(reward).toBeInstanceOf(HeldItemReward);
game.scene.applyReward(reward, []);
expect(true).toBe(true);
const feebas = game.field.getPlayerPokemon();
const reward = generateRewardForTest(RewardId.BERRY)!;
expect(reward).toBeInstanceOf(HeldItemReward); // Replace with actual reward instance
expect(getHeldItemCategory(reward["itemId"])).toBe(HeldItemCategoryId.BERRY);
game.scene.applyReward(reward, { pokemon: feebas });
expect(feebas).toHaveHeldItem(HeldItemCategoryId.BERRY);
});
});

View File

@ -101,8 +101,8 @@ async function promptFileName(selectedType) {
*/
function getBoilerplatePath(choiceType) {
switch (choiceType) {
// case "Reward":
// return path.join(__dirname, "boilerplates/reward.ts");
case "Reward":
return path.join(__dirname, "boilerplates/rewards/reward.ts");
default:
return path.join(__dirname, "boilerplates/default.ts");
}

View File

@ -275,7 +275,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
private shinySparkle: Phaser.GameObjects.Sprite;
public heldItemManager: HeldItemManager;
public readonly heldItemManager: HeldItemManager = new HeldItemManager();
// TODO: Rework this eventually
constructor(

View File

@ -185,6 +185,7 @@ type ApplyHeldItemsParams = {
export function applyHeldItems<T extends HeldItemEffect>(effect: T, params: ApplyHeldItemsParams[T]) {
const pokemon = params.pokemon;
if (pokemon) {
// TODO: Make this use `getHeldItems` and make `heldItems` array private
for (const item of Object.keys(pokemon.heldItemManager.heldItems)) {
if (allHeldItems[item].effects.includes(effect)) {
allHeldItems[item].apply(params);

View File

@ -32,7 +32,7 @@ export function isHeldItemSpecs(entry: any): entry is HeldItemSpecs {
}
// Types used for form change items
interface FormChangeItemData {
export interface FormChangeItemData {
active: boolean;
}

View File

@ -8,6 +8,7 @@ import {
isItemInRequested,
} from "#enums/held-item-id";
import {
type FormChangeItemData,
type FormChangeItemPropertyMap,
type FormChangeItemSpecs,
type HeldItemConfiguration,
@ -16,9 +17,10 @@ import {
type HeldItemSpecs,
isHeldItemSpecs,
} from "#items/held-item-data-types";
import { getTypedEntries, getTypedKeys } from "#utils/common";
import { getTypedKeys } from "#utils/common";
export class HeldItemManager {
// TODO: There should be a way of making these private...
public heldItems: HeldItemDataMap;
public formChangeItems: FormChangeItemPropertyMap;
@ -41,13 +43,14 @@ export class HeldItemManager {
generateHeldItemConfiguration(restrictedIds?: HeldItemId[]): HeldItemConfiguration {
const config: HeldItemConfiguration = [];
for (const [id, item] of getTypedEntries(this.heldItems)) {
for (const [id, item] of this.getHeldItemEntries()) {
// TODO: `in` breaks with arrays
if (item && (!restrictedIds || id in restrictedIds)) {
const specs: HeldItemSpecs = { ...item, id };
config.push({ entry: specs, count: 1 });
}
}
for (const [id, item] of getTypedEntries(this.formChangeItems)) {
for (const [id, item] of this.getFormChangeItemEntries()) {
if (item) {
const specs: FormChangeItemSpecs = { ...item, id };
config.push({ entry: specs, count: 1 });
@ -58,13 +61,13 @@ export class HeldItemManager {
generateSaveData(): HeldItemSaveData {
const saveData: HeldItemSaveData = [];
for (const [id, item] of getTypedEntries(this.heldItems)) {
for (const [id, item] of this.getHeldItemEntries()) {
if (item) {
const specs: HeldItemSpecs = { ...item, id };
saveData.push(specs);
}
}
for (const [id, item] of getTypedEntries(this.formChangeItems)) {
for (const [id, item] of this.getFormChangeItemEntries()) {
if (item) {
const specs: FormChangeItemSpecs = { ...item, id };
saveData.push(specs);
@ -77,28 +80,32 @@ export class HeldItemManager {
return getTypedKeys(this.heldItems);
}
private getHeldItemEntries(): [HeldItemId, HeldItemSpecs][] {
return Object.entries(this.heldItems) as unknown as [HeldItemId, HeldItemSpecs][];
}
getTransferableHeldItems(): HeldItemId[] {
return getTypedKeys(this.heldItems).filter(k => allHeldItems[k].isTransferable);
return this.getHeldItems().filter(k => allHeldItems[k].isTransferable);
}
getStealableHeldItems(): HeldItemId[] {
return getTypedKeys(this.heldItems).filter(k => allHeldItems[k].isStealable);
return this.getHeldItems().filter(k => allHeldItems[k].isStealable);
}
getSuppressableHeldItems(): HeldItemId[] {
return getTypedKeys(this.heldItems).filter(k => allHeldItems[k].isSuppressable);
return this.getHeldItems().filter(k => allHeldItems[k].isSuppressable);
}
hasItem(itemType: HeldItemId | HeldItemCategoryId): boolean {
if (isCategoryId(itemType)) {
return getTypedKeys(this.heldItems).some(id => isItemInCategory(id, itemType as HeldItemCategoryId));
return this.getHeldItems().some(id => isItemInCategory(id, itemType as HeldItemCategoryId));
}
return itemType in this.heldItems;
}
hasTransferableItem(itemType: HeldItemId | HeldItemCategoryId): boolean {
if (isCategoryId(itemType)) {
return getTypedKeys(this.heldItems).some(
return this.getHeldItems().some(
id => isItemInCategory(id, itemType as HeldItemCategoryId) && allHeldItems[id].isTransferable,
);
}
@ -128,7 +135,7 @@ export class HeldItemManager {
overrideItems(newItems: HeldItemDataMap) {
this.heldItems = newItems;
// The following is to allow randomly generated item configs to have stack 0
for (const [item, properties] of getTypedEntries(this.heldItems)) {
for (const [item, properties] of this.getHeldItemEntries()) {
if (!properties || properties.stack <= 0) {
delete this.heldItems[item];
}
@ -176,6 +183,7 @@ export class HeldItemManager {
item.stack -= removeStack;
if (all || item.stack <= 0) {
// TODO: Delete is bad for performance
delete this.heldItems[itemType];
}
}
@ -219,9 +227,14 @@ export class HeldItemManager {
}
getFormChangeItems(): FormChangeItem[] {
// TODO: Please stop using `map(k => k)`
return getTypedKeys(this.formChangeItems).map(k => k);
}
private getFormChangeItemEntries(): [FormChangeItem, FormChangeItemData | undefined][] {
return Object.entries(this.formChangeItems) as unknown as [FormChangeItem, FormChangeItemData | undefined][];
}
getActiveFormChangeItems(): FormChangeItem[] {
return this.getFormChangeItems().filter(m => this.formChangeItems[m]?.active);
}

View File

@ -267,6 +267,7 @@ export function formatStat(stat: number, forHp = false): string {
return formatLargeNumber(stat, forHp ? 100_000 : 1_000_000);
}
// TODO: Remove in place of enum utils
export function getTypedKeys<T extends Record<number, any>, K extends number = Extract<keyof T, number>>(obj: T): K[] {
return Object.keys(obj).map(k => Number(k) as K);
}

View File

@ -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<foo, "qux">
*
* 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<O extends object, K extends keyof O> = AtLeastOne<Omit<nonFunc<O>, K>> & {
[key in K]: O[K];
};

View File

@ -15,6 +15,7 @@ import type { AtLeastOne } from "#types/type-helpers";
import type { expect } from "vitest";
import type Overrides from "#app/overrides";
import type { PokemonMove } from "#moves/pokemon-move";
import { expectedHeldItemType } from "#test/test-utils/matchers/to-have-held-item";
declare module "vitest" {
interface Assertion {
@ -135,5 +136,13 @@ declare module "vitest" {
* or contains the desired move more than once, this will fail the test.
*/
toHaveUsedPP(expectedMove: MoveId, ppUsed: number | "all"): void;
/**
* Check whether a {@linkcode Pokemon} has a given held item.
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @param expectedItem - A {@linkcode HeldItemId} or {@linkcode HeldItemCategoryId} to check, or a partially filled
* {@linkcode HeldItemSpecs} containing the desired values
*/
toHaveHeldItem(expected: expectedHeldItemType): void;
}
}

View File

@ -4,6 +4,7 @@ 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 { toHaveHeldItem } from "#test/test-utils/matchers/to-have-held-item";
import { toHaveHp } from "#test/test-utils/matchers/to-have-hp";
import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage";
import { toHaveStatusEffect } from "#test/test-utils/matchers/to-have-status-effect";
@ -36,4 +37,5 @@ expect.extend({
toHaveHp,
toHaveFainted,
toHaveUsedPP,
toHaveHeldItem,
});

View File

@ -0,0 +1,103 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { HeldItemCategoryId, HeldItemId, isCategoryId } from "#enums/held-item-id";
// biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { Pokemon } from "#field/pokemon";
import type { HeldItemSpecs } from "#items/held-item-data-types";
import type { OneOther } from "#test/@types/test-helpers";
import { getOnelineDiffStr, stringifyEnumArray } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import { enumValueToKey } from "#utils/enums";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export type expectedHeldItemType = HeldItemId | HeldItemCategoryId | OneOther<HeldItemSpecs, "id" | "stack">;
/**
* Matcher that checks if a {@linkcode Pokemon} has a given held item.
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @param expectedItem - A {@linkcode HeldItemId} or {@linkcode HeldItemCategoryId} to check, or a partially filled
* {@linkcode HeldItemSpecs} containing the desired values
* @returns Whether the matcher passed
*/
export function toHaveHeldItem(
this: MatcherState,
received: unknown,
// Simplified typing; full one is in overloads
expectedItem: HeldItemId | HeldItemCategoryId | (Partial<HeldItemSpecs> & { id: HeldItemId }),
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const pkmName = getPokemonNameWithAffix(received);
// If a category was requested OR we lack the item in question, show an error message.
if (typeof expectedItem === "number" || !received.heldItemManager.hasItem(expectedItem.id)) {
expectedItem = typeof expectedItem === "number" ? expectedItem : expectedItem.id;
const pass = received.heldItemManager.hasItem(expectedItem);
const actualStr = stringifyEnumArray(HeldItemId, received.heldItemManager.getHeldItems(), toHexStr);
const expectedStr = itemIdToString(expectedItem);
return {
pass,
// "Expected Magikarp to have an item with category HeldItemCategory.BERRY (=0xADAD), but it didn't!"
message: () =>
pass
? `Expected ${pkmName} to NOT have an item with ${expectedStr}, but it did!`
: `Expected ${pkmName} to have an item with ${expectedStr}, but it didn't!`,
expected: expectedStr,
actual: actualStr,
};
}
// Check the properties of the requested held item
const items = Object.values(received.heldItemManager["heldItems"]);
const pass = items.some(d =>
this.equals(d, expectedItem, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]),
);
// Convert item IDs in the diff into actual numbers
const expectedReadable = {
...expectedItem,
id: toHexStr(expectedItem.id),
};
const actualReadable = received.heldItemManager["getHeldItemEntries"]().map(([id, spec]) => ({
...spec,
id: toHexStr(id),
}));
const expectedStr = getOnelineDiffStr.call(this, expectedReadable);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have an item matching ${expectedStr}, but it did!`
: `Expected ${pkmName} to have an item matching ${expectedStr}, but it didn't!`,
expected: expectedReadable,
actual: actualReadable,
};
}
const PADDING = 4;
/**
* Convert a number into a readable hexadecimal format.
* @param num - The number to convert
* @returns The hex string
*/
function toHexStr(num: number): string {
return `0x${num.toString(16).padStart(PADDING, "0")}`;
}
function itemIdToString(id: HeldItemId | HeldItemCategoryId): string {
if (isCategoryId(id)) {
const catStr = enumValueToKey(HeldItemCategoryId, id);
return `catgeory HeldItemCategory.${catStr} (=${toHexStr(id)})`;
}
const idStr = enumValueToKey(HeldItemId, id);
return `ID HeldItemId.${idStr} (=${toHexStr(id)})`;
}

View File

@ -73,8 +73,9 @@ export function getEnumStr<E extends EnumOrObject>(
/**
* 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 obj - The {@linkcode NormalEnum} to source reverse mappings from
* @param enums - An array of {@linkcode obj}'s values
* @param transformValues - An optional function used to transform `obj`'s values into strings.
* @returns The stringified representation of `enums`.
* @example
* ```ts
@ -86,12 +87,16 @@ export function getEnumStr<E extends EnumOrObject>(
* console.log(stringifyEnumArray(fakeEnum, [fakeEnum.ONE, fakeEnum.TWO, fakeEnum.THREE])); // Output: "[ONE, TWO, THREE] (=[1, 2, 3])"
* ```
*/
export function stringifyEnumArray<E extends EnumOrObject>(obj: E, enums: E[keyof E][]): string {
export function stringifyEnumArray<E extends EnumOrObject>(
obj: E,
enums: ObjectValues<E>[],
transformValues?: (val: (typeof enums)[number]) => string,
): string {
if (obj.length === 0) {
return "[]";
}
const vals = enums.slice();
const vals = transformValues ? enums.map(transformValues) : enums;
/** An array of string names */
let names: string[];

View File

@ -0,0 +1,65 @@
import type { HeldItemId } from "#enums/held-item-id";
import type { RewardId } from "#enums/reward-id";
import type { TrainerItemId } from "#enums/trainer-item-id";
import { allRewards, type allRewardsType } from "#items/all-rewards";
import { HeldItemReward, type Reward, RewardGenerator, TrainerItemReward } from "#items/reward";
import { isHeldItemId, isTrainerItemId } from "#items/reward-utils";
import type { RewardPoolId, RewardSpecs } from "#types/rewards";
// Type used to convert allRewards into a type
type allRewardsRewardType = {
[k in keyof allRewardsType]: allRewardsType[k] extends RewardGenerator
? ReturnType<allRewardsType[k]["generateReward"]>
: allRewardsType[k];
};
/**
* Dynamically generate a {@linkcode Reward} from a given RewardSpecs.
* @param specs - The {@linkcode RewardSpecs} used to generate the reward
* @returns The generated {@linkcode Reward}, or `null` if no reward could be generated
* @todo Remove `null` from signature eventually
* @example
* ```ts
* const reward = generateRewardForTest({id: RewardId.BERRY, args: BerryType.SITRUS});
* ```
*/
export function generateRewardForTest<T extends RewardId>(specs: RewardSpecs<T>): allRewardsRewardType[T] | null;
/**
* Dynamically generate a {@linkcode Reward} from a given HeldItemId.
* @param id - The {@linkcode HeldItemId | ID} of the Held item to generate
* @returns The generated {@linkcode HeldItemReward}, or `null` if no reward could be generated
* @todo Remove `null` from signature eventually
* @example
* ```ts
* const reward = generateRewardForTest(HeldItemId.REVIVER_SEED);
* ```
*/
export function generateRewardForTest<T extends HeldItemId>(id: RewardSpecs<T>): HeldItemReward | null;
/**
* Dynamically generate a {@linkcode Reward} from a given TrainerItemId.
* @param id - The {@linkcode TrainerItemId | ID} of the Trainer item to generate
* @returns The generated {@linkcode TrainerItemReward}, or `null` if no reward could be generated
* @todo Remove `null` from signature eventually
* @example
* ```ts
* const reward = generateRewardForTest(TrainerItemId.HEALING_CHARM);
* ```
*/
export function generateRewardForTest<T extends TrainerItemId>(specs: RewardSpecs<T>): TrainerItemReward | null;
export function generateRewardForTest(specs: RewardSpecs): Reward | null {
// Destructure specs into individual parameters
const pregenArgs = typeof specs === "object" ? specs.args : undefined;
const id: RewardPoolId = typeof specs === "object" ? specs.id : specs;
if (isHeldItemId(id)) {
return new HeldItemReward(id);
}
if (isTrainerItemId(id)) {
return new TrainerItemReward(id);
}
const rewardFunc = allRewards[id];
// @ts-expect-error - We enforce call safety using overloads
return rewardFunc instanceof RewardGenerator ? rewardFunc.generateReward(pregenArgs) : rewardFunc;
}

View File

@ -54,6 +54,7 @@
"#utils/*": ["./utils/*.ts"],
"#data/*": ["./data/pokemon-forms/*.ts", "./data/pokemon/*.ts", "./data/*.ts"],
"#test/*": ["../test/*.ts"],
"#test/test-utils/*": ["../test/test-utils/utils/*.ts", "../test/test-utils/*.ts"],
"#app/*": ["*.ts"]
},
"outDir": "./build",