From e05d85977ee7d951b85ac1941a1ff58232637a9a Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:14:02 +0200 Subject: [PATCH] [Dev] Updated enum utils to have strong typing (#6024) * Updated enum utils to refuse non-enum values; added strong typing on return values * Moved Enum functions to own file; added type helpers for enums * Cleaned up some code * Fixed up tests * Fix training-session-encounter.ts --- src/@types/enum-types.ts | 18 +++ src/@types/type-helpers.ts | 22 +++- src/battle-scene.ts | 5 +- src/battle.ts | 2 +- src/data/balance/biomes.ts | 7 +- src/data/balance/egg-moves.ts | 23 ++-- src/data/battle-anims.ts | 15 +-- src/data/daily-run.ts | 4 +- src/data/dialogue.ts | 1 + src/data/moves/move.ts | 3 +- .../encounters/berries-abound-encounter.ts | 5 +- .../encounters/bug-type-superfan-encounter.ts | 2 + .../encounters/clowning-around-encounter.ts | 1 + .../teleporting-hijinks-encounter.ts | 1 + .../encounters/training-session-encounter.ts | 3 +- .../encounters/weird-dream-encounter.ts | 2 + .../mystery-encounter-option.ts | 2 + .../mystery-encounters/mystery-encounter.ts | 2 + .../utils/encounter-phase-utils.ts | 2 + .../utils/encounter-pokemon-utils.ts | 4 + src/field/arena.ts | 2 + src/field/pokemon.ts | 2 +- src/field/trainer.ts | 5 +- src/inputs-controller.ts | 2 +- src/loading-scene.ts | 3 +- src/modifier/modifier-type.ts | 16 +-- src/phases/move-phase.ts | 3 +- src/phases/select-biome-phase.ts | 1 + src/system/game-data.ts | 3 +- src/ui/daily-run-scoreboard.ts | 3 +- src/ui/egg-gacha-ui-handler.ts | 3 +- src/ui/menu-ui-handler.ts | 27 ++--- src/ui/pokedex-page-ui-handler.ts | 6 +- src/ui/summary-ui-handler.ts | 2 +- src/utils/common.ts | 44 ++------ src/utils/enums.ts | 74 +++++++++++++ test/types/enum-types.test-d.ts | 104 ++++++++++++++++++ vitest.config.ts | 4 + 38 files changed, 322 insertions(+), 106 deletions(-) create mode 100644 src/@types/enum-types.ts create mode 100644 src/utils/enums.ts create mode 100644 test/types/enum-types.test-d.ts diff --git a/src/@types/enum-types.ts b/src/@types/enum-types.ts new file mode 100644 index 00000000000..84df0a96505 --- /dev/null +++ b/src/@types/enum-types.ts @@ -0,0 +1,18 @@ +/** Union type accepting any TS Enum or `const object`, with or without reverse mapping. */ +export type EnumOrObject = Record; + +/** + * Utility type to extract the enum values from a `const object`, + * or convert an `enum` interface produced by `typeof Enum` into the union type representing its values. + */ +export type EnumValues = E[keyof E]; + +/** + * Generic type constraint representing a TS numeric enum with reverse mappings. + * @example + * TSNumericEnum + */ +export type TSNumericEnum = number extends EnumValues ? T : never; + +/** Generic type constraint representing a non reverse-mapped TS enum or `const object`. */ +export type NormalEnum = Exclude>; diff --git a/src/@types/type-helpers.ts b/src/@types/type-helpers.ts index eac268cb189..077bef62f1f 100644 --- a/src/@types/type-helpers.ts +++ b/src/@types/type-helpers.ts @@ -2,8 +2,11 @@ * A collection of custom utility types that aid in type checking and ensuring strict type conformity */ -// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment -import type { AbAttr } from "#types/ability-types"; +// biome-ignore-start lint/correctness/noUnusedImports: Used in a tsdoc comment +import type { AbAttr } from "#abilities/ability"; +// biome-ignore-end lint/correctness/noUnusedImports: Used in a tsdoc comment + +import type { EnumValues } from "#types/enum-types"; /** * Exactly matches the type of the argument, preventing adding additional properties. @@ -11,7 +14,7 @@ import type { AbAttr } from "#types/ability-types"; * ⚠️ Should never be used with `extends`, as this will nullify the exactness of the type. * * As an example, used to ensure that the parameters of {@linkcode AbAttr.canApply} and {@linkcode AbAttr.getTriggerMessage} are compatible with - * the type of the apply method + * the type of its {@linkcode AbAttr.apply | apply} method. * * @typeParam T - The type to match exactly */ @@ -26,9 +29,18 @@ export type Exact = { export type Closed = X; /** - * Remove `readonly` from all properties of the provided type - * @typeParam T - The type to make mutable + * Remove `readonly` from all properties of the provided type. + * @typeParam T - The type to make mutable. */ export type Mutable = { -readonly [P in keyof T]: T[P]; }; + +/** + * Type helper to obtain the keys associated with a given value inside a `const object`. + * @typeParam O - The type of the object + * @typeParam V - The type of one of O's values + */ +export type InferKeys, V extends EnumValues> = { + [K in keyof O]: O[K] extends V ? K : never; +}[keyof O]; diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 3807f54ba84..ad0c9d84aba 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -140,7 +140,6 @@ import { type Constructor, fixedInt, formatMoney, - getEnumValues, getIvsFromId, isBetween, isNullOrUndefined, @@ -150,6 +149,7 @@ import { shiftCharCodes, } from "#utils/common"; import { deepMergeSpriteData } from "#utils/data"; +import { getEnumValues } from "#utils/enums"; import { getModifierPoolForType, getModifierType } from "#utils/modifier-utils"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; @@ -2178,6 +2178,7 @@ export class BattleScene extends SceneBase { ), ] : allSpecies.filter(s => s.isCatchable()); + // TODO: should this use `randSeedItem`? return filteredSpecies[randSeedInt(filteredSpecies.length)]; } @@ -2203,6 +2204,7 @@ export class BattleScene extends SceneBase { } } + // TODO: should this use `randSeedItem`? return biomes[randSeedInt(biomes.length)]; } @@ -3726,6 +3728,7 @@ export class BattleScene extends SceneBase { console.log("No Mystery Encounters found, falling back to Mysterious Challengers."); return allMysteryEncounters[MysteryEncounterType.MYSTERIOUS_CHALLENGERS]; } + // TODO: should this use `randSeedItem`? encounter = availableEncounters[randSeedInt(availableEncounters.length)]; // New encounter object to not dirty flags encounter = new MysteryEncounter(encounter); diff --git a/src/battle.ts b/src/battle.ts index 5ef9c1f2207..74cde8714b9 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -26,7 +26,6 @@ import { MusicPreference } from "#system/settings"; import { trainerConfigs } from "#trainers/trainer-config"; import type { TurnMove } from "#types/turn-move"; import { - getEnumValues, NumberHolder, randInt, randomString, @@ -35,6 +34,7 @@ import { randSeedItem, shiftCharCodes, } from "#utils/common"; +import { getEnumValues } from "#utils/enums"; export interface TurnCommand { command: Command; diff --git a/src/data/balance/biomes.ts b/src/data/balance/biomes.ts index eefe7633d94..feff8a0a77b 100644 --- a/src/data/balance/biomes.ts +++ b/src/data/balance/biomes.ts @@ -5,7 +5,8 @@ import { PokemonType } from "#enums/pokemon-type"; import { SpeciesId } from "#enums/species-id"; import { TimeOfDay } from "#enums/time-of-day"; import { TrainerType } from "#enums/trainer-type"; -import { getEnumValues, randSeedInt } from "#utils/common"; +import { randSeedInt } from "#utils/common"; +import { getEnumValues } from "#utils/enums"; import i18next from "i18next"; export function getBiomeName(biome: BiomeId | -1) { @@ -7707,10 +7708,10 @@ export function initBiomes() { const traverseBiome = (biome: BiomeId, depth: number) => { if (biome === BiomeId.END) { - const biomeList = Object.keys(BiomeId).filter(key => !Number.isNaN(Number(key))); + const biomeList = getEnumValues(BiomeId) biomeList.pop(); // Removes BiomeId.END from the list const randIndex = randSeedInt(biomeList.length, 1); // Will never be BiomeId.TOWN - biome = BiomeId[biomeList[randIndex]]; + biome = biomeList[randIndex]; } const linkedBiomes: (BiomeId | [ BiomeId, number ])[] = Array.isArray(biomeLinks[biome]) ? biomeLinks[biome] as (BiomeId | [ BiomeId, number ])[] diff --git a/src/data/balance/egg-moves.ts b/src/data/balance/egg-moves.ts index 7d5c3cb5168..f5026abe2ef 100644 --- a/src/data/balance/egg-moves.ts +++ b/src/data/balance/egg-moves.ts @@ -1,8 +1,8 @@ import { allMoves } from "#data/data-lists"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; -import { getEnumKeys, getEnumValues } from "#utils/common"; - +import { toReadableString } from "#utils/common"; +import { getEnumKeys, getEnumValues } from "#utils/enums"; export const speciesEggMoves = { [SpeciesId.BULBASAUR]: [ MoveId.SAPPY_SEED, MoveId.MALIGNANT_CHAIN, MoveId.EARTH_POWER, MoveId.MATCHA_GOTCHA ], @@ -584,17 +584,24 @@ export const speciesEggMoves = { [SpeciesId.BLOODMOON_URSALUNA]: [ MoveId.NASTY_PLOT, MoveId.ROCK_POLISH, MoveId.SANDSEAR_STORM, MoveId.BOOMBURST ] }; +/** + * Parse a CSV-separated list of Egg Moves (such as one sourced from a Google Sheets) + * into code able to form the `speciesEggMoves` const object as above. + * @param content - The CSV-formatted string to convert into code. + */ +// TODO: Move this into the scripts folder and stop running it on initialization function parseEggMoves(content: string): void { let output = ""; const speciesNames = getEnumKeys(SpeciesId); const speciesValues = getEnumValues(SpeciesId); + const moveNames = allMoves.map(m => m.name.replace(/ \([A-Z]\)$/, "").toLowerCase()); const lines = content.split(/\n/g); for (const line of lines) { const cols = line.split(",").slice(0, 5); - const moveNames = allMoves.map(m => m.name.replace(/ \([A-Z]\)$/, "").toLowerCase()); - const enumSpeciesName = cols[0].toUpperCase().replace(/[ -]/g, "_"); + const enumSpeciesName = cols[0].toUpperCase().replace(/[ -]/g, "_") as keyof typeof SpeciesId; + // TODO: This should use reverse mapping instead of `indexOf` const species = speciesValues[speciesNames.indexOf(enumSpeciesName)]; const eggMoves: MoveId[] = []; @@ -602,14 +609,16 @@ function parseEggMoves(content: string): void { for (let m = 0; m < 4; m++) { const moveName = cols[m + 1].trim(); const moveIndex = moveName !== "N/A" ? moveNames.indexOf(moveName.toLowerCase()) : -1; - eggMoves.push(moveIndex > -1 ? moveIndex as MoveId : MoveId.NONE); - if (moveIndex === -1) { console.warn(moveName, "could not be parsed"); } + + eggMoves.push(moveIndex > -1 ? moveIndex as MoveId : MoveId.NONE); } - if (eggMoves.find(m => m !== MoveId.NONE)) { + if (eggMoves.every(m => m === MoveId.NONE)) { + console.warn(`Species ${toReadableString(SpeciesId[species])} could not be parsed, excluding from output...`) + } else { output += `[SpeciesId.${SpeciesId[species]}]: [ ${eggMoves.map(m => `MoveId.${MoveId[m]}`).join(", ")} ],\n`; } } diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index 58422ad3f51..d26f0700f7f 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -7,15 +7,8 @@ import { MoveFlags } from "#enums/MoveFlags"; import { AnimBlendType, AnimFocus, AnimFrameTarget, ChargeAnim, CommonAnim } from "#enums/move-anims-common"; import { MoveId } from "#enums/move-id"; import type { Pokemon } from "#field/pokemon"; -import { - animationFileName, - coerceArray, - getEnumKeys, - getEnumValues, - getFrameMs, - isNullOrUndefined, - type nil, -} from "#utils/common"; +import { animationFileName, coerceArray, getFrameMs, isNullOrUndefined, type nil } from "#utils/common"; +import { getEnumKeys, getEnumValues } from "#utils/enums"; import Phaser from "phaser"; export class AnimConfig { @@ -1406,10 +1399,10 @@ export class EncounterBattleAnim extends BattleAnim { export async function populateAnims() { const commonAnimNames = getEnumKeys(CommonAnim).map(k => k.toLowerCase()); const commonAnimMatchNames = commonAnimNames.map(k => k.replace(/_/g, "")); - const commonAnimIds = getEnumValues(CommonAnim) as CommonAnim[]; + const commonAnimIds = getEnumValues(CommonAnim); const chargeAnimNames = getEnumKeys(ChargeAnim).map(k => k.toLowerCase()); const chargeAnimMatchNames = chargeAnimNames.map(k => k.replace(/_/g, " ")); - const chargeAnimIds = getEnumValues(ChargeAnim) as ChargeAnim[]; + const chargeAnimIds = getEnumValues(ChargeAnim); const commonNamePattern = /name: (?:Common:)?(Opp )?(.*)/; const moveNameToId = {}; for (const move of getEnumValues(MoveId).slice(1)) { diff --git a/src/data/daily-run.ts b/src/data/daily-run.ts index e2cb2a8ca35..9a6f560933a 100644 --- a/src/data/daily-run.ts +++ b/src/data/daily-run.ts @@ -8,7 +8,8 @@ import { PartyMemberStrength } from "#enums/party-member-strength"; import type { SpeciesId } from "#enums/species-id"; import { PlayerPokemon } from "#field/pokemon"; import type { Starter } from "#ui/starter-select-ui-handler"; -import { getEnumValues, randSeedGauss, randSeedInt, randSeedItem } from "#utils/common"; +import { randSeedGauss, randSeedInt, randSeedItem } from "#utils/common"; +import { getEnumValues } from "#utils/enums"; import { getPokemonSpecies } from "#utils/pokemon-utils"; export interface DailyRunConfig { @@ -165,5 +166,6 @@ export function getDailyStartingBiome(): BiomeId { } // Fallback in case something went wrong + // TODO: should this use `randSeedItem`? return biomes[randSeedInt(biomes.length)]; } diff --git a/src/data/dialogue.ts b/src/data/dialogue.ts index 1296b0570e2..406e72ee82b 100644 --- a/src/data/dialogue.ts +++ b/src/data/dialogue.ts @@ -1744,6 +1744,7 @@ export function getCharVariantFromDialogue(message: string): string { } export function initTrainerTypeDialogue(): void { + // TODO: this should not be using `Object.Keys` const trainerTypes = Object.keys(trainerTypeDialogue).map(t => Number.parseInt(t) as TrainerType); for (const trainerType of trainerTypes) { const messages = trainerTypeDialogue[trainerType]; diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 19720bb9fc0..0b0a7e4a853 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -87,7 +87,8 @@ import type { AttackMoveResult } from "#types/attack-move-result"; import type { Localizable } from "#types/locales"; import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString } from "#types/move-types"; import type { TurnMove } from "#types/turn-move"; -import { BooleanHolder, type Constructor, getEnumValues, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue, toReadableString } from "#utils/common"; +import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue, toReadableString } from "#utils/common"; +import { getEnumValues } from "#utils/enums"; import i18next from "i18next"; /** diff --git a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts index d39f5930d46..a827c3fcc0a 100644 --- a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts +++ b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts @@ -36,7 +36,8 @@ import { MysteryEncounterBuilder } from "#mystery-encounters/mystery-encounter"; import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option"; import i18next from "#plugins/i18n"; import { PokemonData } from "#system/pokemon-data"; -import { randSeedInt } from "#utils/common"; +import { randSeedItem } from "#utils/common"; +import { getEnumValues } from "#utils/enums"; /** the i18n namespace for the encounter */ const namespace = "mysteryEncounters/berriesAbound"; @@ -310,7 +311,7 @@ export const BerriesAboundEncounter: MysteryEncounter = MysteryEncounterBuilder. .build(); function tryGiveBerry(prioritizedPokemon?: PlayerPokemon) { - const berryType = randSeedInt(Object.keys(BerryType).filter(s => !Number.isNaN(Number(s))).length) as BerryType; + const berryType = randSeedItem(getEnumValues(BerryType)); const berry = generateModifierType(modifierTypes.BERRY, [berryType]) as BerryModifierType; const party = globalScene.getPlayerParty(); diff --git a/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts index 299c14b21b6..d68dc3d12e0 100644 --- a/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts +++ b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts @@ -289,6 +289,7 @@ export const BugTypeSuperfanEncounter: MysteryEncounter = MysteryEncounterBuilde // Init the moves available for tutor const moveTutorOptions: PokemonMove[] = []; + // TODO: should this use `randSeedItem`? moveTutorOptions.push(new PokemonMove(PHYSICAL_TUTOR_MOVES[randSeedInt(PHYSICAL_TUTOR_MOVES.length)])); moveTutorOptions.push(new PokemonMove(SPECIAL_TUTOR_MOVES[randSeedInt(SPECIAL_TUTOR_MOVES.length)])); moveTutorOptions.push(new PokemonMove(STATUS_TUTOR_MOVES[randSeedInt(STATUS_TUTOR_MOVES.length)])); @@ -386,6 +387,7 @@ export const BugTypeSuperfanEncounter: MysteryEncounter = MysteryEncounterBuilde specialOptions.push(rareFormChangeModifier); } if (specialOptions.length > 0) { + // TODO: should this use `randSeedItem`? modifierOptions.push(specialOptions[randSeedInt(specialOptions.length)]); } diff --git a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts index 5e3ece7b301..e0a24ab0011 100644 --- a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts +++ b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts @@ -138,6 +138,7 @@ export const ClowningAroundEncounter: MysteryEncounter = MysteryEncounterBuilder clownConfig.partyTemplateFunc = null; // Overrides party template func if it exists // Generate random ability for Blacephalon from pool + // TODO: should this use `randSeedItem`? const ability = RANDOM_ABILITY_POOL[randSeedInt(RANDOM_ABILITY_POOL.length)]; encounter.setDialogueToken("ability", allAbilities[ability].name); encounter.misc = { ability }; diff --git a/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts index acebb513c00..b547064fd66 100644 --- a/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts +++ b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts @@ -190,6 +190,7 @@ async function doBiomeTransitionDialogueAndBattleInit() { // Calculate new biome (cannot be current biome) const filteredBiomes = BIOME_CANDIDATES.filter(b => globalScene.arena.biomeType !== b); + // TODO: should this use `randSeedItem`? const newBiome = filteredBiomes[randSeedInt(filteredBiomes.length)]; // Show dialogue and transition biome diff --git a/src/data/mystery-encounters/encounters/training-session-encounter.ts b/src/data/mystery-encounters/encounters/training-session-encounter.ts index f561c8240cd..393f8a24e51 100644 --- a/src/data/mystery-encounters/encounters/training-session-encounter.ts +++ b/src/data/mystery-encounters/encounters/training-session-encounter.ts @@ -28,7 +28,8 @@ import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encou import { PokemonData } from "#system/pokemon-data"; import type { HeldModifierConfig } from "#types/held-modifier-config"; import type { OptionSelectItem } from "#ui/abstact-option-select-ui-handler"; -import { getEnumValues, isNullOrUndefined, randSeedShuffle } from "#utils/common"; +import { isNullOrUndefined, randSeedShuffle } from "#utils/common"; +import { getEnumValues } from "#utils/enums"; import i18next from "i18next"; /** The i18n namespace for the encounter */ diff --git a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts index bb8a3c732a7..32e95435547 100644 --- a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts +++ b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts @@ -305,6 +305,7 @@ export const WeirdDreamEncounter: MysteryEncounter = MysteryEncounterBuilder.wit // One random pokemon will get its passive unlocked const passiveDisabledPokemon = globalScene.getPlayerParty().filter(p => !p.passive); if (passiveDisabledPokemon?.length > 0) { + // TODO: should this use `randSeedItem`? const enablePassiveMon = passiveDisabledPokemon[randSeedInt(passiveDisabledPokemon.length)]; enablePassiveMon.passive = true; enablePassiveMon.updateInfo(true); @@ -466,6 +467,7 @@ async function doNewTeamPostProcess(transformations: PokemonTransformation[]) { // One random pokemon will get its passive unlocked const passiveDisabledPokemon = globalScene.getPlayerParty().filter(p => !p.passive); if (passiveDisabledPokemon?.length > 0) { + // TODO: should this use `randSeedItem`? const enablePassiveMon = passiveDisabledPokemon[randSeedInt(passiveDisabledPokemon.length)]; enablePassiveMon.passive = true; await enablePassiveMon.updateInfo(true); diff --git a/src/data/mystery-encounters/mystery-encounter-option.ts b/src/data/mystery-encounters/mystery-encounter-option.ts index 24707683a82..504310eeabd 100644 --- a/src/data/mystery-encounters/mystery-encounter-option.ts +++ b/src/data/mystery-encounters/mystery-encounter-option.ts @@ -144,11 +144,13 @@ export class MysteryEncounterOption implements IMysteryEncounterOption { } if (truePrimaryPool.length > 0) { // always choose from the non-overlapping pokemon first + // TODO: should this use `randSeedItem`? this.primaryPokemon = truePrimaryPool[randSeedInt(truePrimaryPool.length)]; return true; } // if there are multiple overlapping pokemon, we're okay - just choose one and take it out of the supporting pokemon pool if (overlap.length > 1 || this.secondaryPokemon.length - overlap.length >= 1) { + // TODO: should this use `randSeedItem`? this.primaryPokemon = overlap[randSeedInt(overlap.length)]; this.secondaryPokemon = this.secondaryPokemon.filter(supp => supp !== this.primaryPokemon); return true; diff --git a/src/data/mystery-encounters/mystery-encounter.ts b/src/data/mystery-encounters/mystery-encounter.ts index 00e81598380..a2ca2b20ce7 100644 --- a/src/data/mystery-encounters/mystery-encounter.ts +++ b/src/data/mystery-encounters/mystery-encounter.ts @@ -381,6 +381,7 @@ export class MysteryEncounter implements IMysteryEncounter { // If there are multiple overlapping pokemon, we're okay - just choose one and take it out of the primary pokemon pool if (overlap.length > 1 || this.secondaryPokemon.length - overlap.length >= 1) { // is this working? + // TODO: should this use `randSeedItem`? this.primaryPokemon = overlap[randSeedInt(overlap.length, 0)]; this.secondaryPokemon = this.secondaryPokemon.filter(supp => supp !== this.primaryPokemon); return true; @@ -391,6 +392,7 @@ export class MysteryEncounter implements IMysteryEncounter { return false; } // this means we CAN have the same pokemon be a primary and secondary pokemon, so just choose any qualifying one randomly. + // TODO: should this use `randSeedItem`? this.primaryPokemon = qualified[randSeedInt(qualified.length, 0)]; return true; } diff --git a/src/data/mystery-encounters/utils/encounter-phase-utils.ts b/src/data/mystery-encounters/utils/encounter-phase-utils.ts index 68c49e121ef..6b085978b27 100644 --- a/src/data/mystery-encounters/utils/encounter-phase-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-phase-utils.ts @@ -1098,8 +1098,10 @@ export function calculateMEAggregateStats(baseSpawnWeight: number) { if (biomes! && biomes.length > 0) { const specialBiomes = biomes.filter(b => alwaysPickTheseBiomes.includes(b)); if (specialBiomes.length > 0) { + // TODO: should this use `randSeedItem`? currentBiome = specialBiomes[randSeedInt(specialBiomes.length)]; } else { + // TODO: should this use `randSeedItem`? currentBiome = biomes[randSeedInt(biomes.length)]; } } diff --git a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts index 5afb07b9bc4..aa73cc3c2a3 100644 --- a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts @@ -110,20 +110,24 @@ export function getRandomPlayerPokemon( // If there is only 1 legal/unfainted mon left, select from fainted legal mons const faintedLegalMons = party.filter(p => (!isAllowed || p.isAllowedInChallenge()) && p.isFainted()); if (faintedLegalMons.length > 0) { + // TODO: should this use `randSeedItem`? chosenIndex = randSeedInt(faintedLegalMons.length); chosenPokemon = faintedLegalMons[chosenIndex]; } } if (!chosenPokemon && fullyLegalMons.length > 0) { + // TODO: should this use `randSeedItem`? chosenIndex = randSeedInt(fullyLegalMons.length); chosenPokemon = fullyLegalMons[chosenIndex]; } if (!chosenPokemon && isAllowed && allowedOnlyMons.length > 0) { + // TODO: should this use `randSeedItem`? chosenIndex = randSeedInt(allowedOnlyMons.length); chosenPokemon = allowedOnlyMons[chosenIndex]; } if (!chosenPokemon) { // If no other options worked, returns fully random + // TODO: should this use `randSeedItem`? chosenIndex = randSeedInt(party.length); chosenPokemon = party[chosenIndex]; } diff --git a/src/field/arena.ts b/src/field/arena.ts index f8c429e88b5..763c1d595c0 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -143,6 +143,7 @@ export class Arena { if (!tierPool.length) { ret = globalScene.randomSpecies(waveIndex, level); } else { + // TODO: should this use `randSeedItem`? const entry = tierPool[randSeedInt(tierPool.length)]; let species: SpeciesId; if (typeof entry === "number") { @@ -154,6 +155,7 @@ export class Arena { if (level >= levelThreshold) { const speciesIds = entry[levelThreshold]; if (speciesIds.length > 1) { + // TODO: should this use `randSeedItem`? species = speciesIds[randSeedInt(speciesIds.length)]; } else { species = speciesIds[0]; diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 6e68e4c2fb8..bebb218dce2 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -155,7 +155,6 @@ import { coerceArray, deltaRgb, fixedInt, - getEnumValues, getIvsFromId, isBetween, isNullOrUndefined, @@ -169,6 +168,7 @@ import { rgbToHsv, toDmgValue, } from "#utils/common"; +import { getEnumValues } from "#utils/enums"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import { argbFromRgba, QuantizerCelebi, rgbaFromArgb } from "@material/material-color-utilities"; import i18next from "i18next"; diff --git a/src/field/trainer.ts b/src/field/trainer.ts index f0641215c4f..db7a332064b 100644 --- a/src/field/trainer.ts +++ b/src/field/trainer.ts @@ -420,7 +420,8 @@ export class Trainer extends Phaser.GameObjects.Container { // If useNewSpeciesPool is true, we need to generate a new species from the new species pool, otherwise we generate a random species let species = useNewSpeciesPool - ? getPokemonSpecies(newSpeciesPool[Math.floor(randSeedInt(newSpeciesPool.length))]) + ? // TODO: should this use `randSeedItem`? + getPokemonSpecies(newSpeciesPool[Math.floor(randSeedInt(newSpeciesPool.length))]) : template.isSameSpecies(index) && index > offset ? getPokemonSpecies( battle.enemyParty[offset].species.getTrainerSpeciesForLevel( @@ -618,6 +619,8 @@ export class Trainer extends Phaser.GameObjects.Container { if (maxScorePartyMemberIndexes.length > 1) { let rand: number; + // TODO: should this use `randSeedItem`? + globalScene.executeWithSeedOffset( () => (rand = randSeedInt(maxScorePartyMemberIndexes.length)), globalScene.currentBattle.turn << 2, diff --git a/src/inputs-controller.ts b/src/inputs-controller.ts index 2eeae10ba6b..1607e4ee74f 100644 --- a/src/inputs-controller.ts +++ b/src/inputs-controller.ts @@ -15,8 +15,8 @@ import type { SettingKeyboard } from "#system/settings-keyboard"; import { MoveTouchControlsHandler } from "#ui/move-touch-controls-handler"; import type { SettingsGamepadUiHandler } from "#ui/settings-gamepad-ui-handler"; import type { SettingsKeyboardUiHandler } from "#ui/settings-keyboard-ui-handler"; -import { getEnumValues } from "#utils/common"; import { deepCopy } from "#utils/data"; +import { getEnumValues } from "#utils/enums"; import Phaser from "phaser"; export interface DeviceMapping { diff --git a/src/loading-scene.ts b/src/loading-scene.ts index 46efa2a02c3..eb6883e0c68 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -21,7 +21,8 @@ import { initAchievements } from "#system/achv"; import { initVouchers } from "#system/voucher"; import { initStatsKeys } from "#ui/game-stats-ui-handler"; import { getWindowVariantSuffix, WindowVariant } from "#ui/ui-theme"; -import { getEnumValues, hasAllLocalizedSprites, localPing } from "#utils/common"; +import { hasAllLocalizedSprites, localPing } from "#utils/common"; +import { getEnumValues } from "#utils/enums"; import i18next from "i18next"; export class LoadingScene extends SceneBase { diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index 56db26feecb..b359ec756e6 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -116,15 +116,8 @@ import type { ModifierTypeFunc, WeightedModifierTypeWeightFunc } from "#types/mo import type { PokemonMoveSelectFilter, PokemonSelectFilter } from "#ui/party-ui-handler"; import { PartyUiHandler } from "#ui/party-ui-handler"; import { getModifierTierTextTint } from "#ui/text"; -import { - formatMoney, - getEnumKeys, - getEnumValues, - isNullOrUndefined, - NumberHolder, - padInt, - randSeedInt, -} from "#utils/common"; +import { formatMoney, isNullOrUndefined, NumberHolder, padInt, randSeedInt, randSeedItem } from "#utils/common"; +import { getEnumKeys, getEnumValues } from "#utils/enums"; import { getModifierPoolForType, getModifierType } from "#utils/modifier-utils"; import i18next from "i18next"; @@ -1524,6 +1517,7 @@ class TmModifierTypeGenerator extends ModifierTypeGenerator { if (!tierUniqueCompatibleTms.length) { return null; } + // TODO: should this use `randSeedItem`? const randTmIndex = randSeedInt(tierUniqueCompatibleTms.length); return new TmModifierType(tierUniqueCompatibleTms[randTmIndex]); }); @@ -1577,6 +1571,7 @@ class EvolutionItemModifierTypeGenerator extends ModifierTypeGenerator { return null; } + // TODO: should this use `randSeedItem`? return new EvolutionItemModifierType(evolutionItemPool[randSeedInt(evolutionItemPool.length)]!); // TODO: is the bang correct? }); } @@ -1662,6 +1657,7 @@ export class FormChangeItemModifierTypeGenerator extends ModifierTypeGenerator { return null; } + // TODO: should this use `randSeedItem`? return new FormChangeItemModifierType(formChangeItemPool[randSeedInt(formChangeItemPool.length)]); }); } @@ -1932,7 +1928,7 @@ const modifierTypeInitObj = Object.freeze({ if (pregenArgs && pregenArgs.length === 1 && pregenArgs[0] in Nature) { return new PokemonNatureChangeModifierType(pregenArgs[0] as Nature); } - return new PokemonNatureChangeModifierType(randSeedInt(getEnumValues(Nature).length) as Nature); + return new PokemonNatureChangeModifierType(randSeedItem(getEnumValues(Nature))); }), MYSTICAL_ROCK: () => diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index ccaebd8a3e4..09a542861be 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -26,7 +26,8 @@ import { applyMoveAttrs } from "#moves/apply-attrs"; import { frenzyMissFunc } from "#moves/move-utils"; import type { PokemonMove } from "#moves/pokemon-move"; import { BattlePhase } from "#phases/battle-phase"; -import { enumValueToKey, NumberHolder } from "#utils/common"; +import { NumberHolder } from "#utils/common"; +import { enumValueToKey } from "#utils/enums"; import i18next from "i18next"; export class MovePhase extends BattlePhase { diff --git a/src/phases/select-biome-phase.ts b/src/phases/select-biome-phase.ts index f54900b15b5..ab96bf5c45e 100644 --- a/src/phases/select-biome-phase.ts +++ b/src/phases/select-biome-phase.ts @@ -56,6 +56,7 @@ export class SelectBiomePhase extends BattlePhase { delay: 1000, }); } else { + // TODO: should this use `randSeedItem`? setNextBiome(biomes[randSeedInt(biomes.length)]); } } else if (biomeLinks.hasOwnProperty(currentBiome)) { diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 32733a6ec50..6abb5518d1c 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -62,8 +62,9 @@ import { VoucherType, vouchers } from "#system/voucher"; import { trainerConfigs } from "#trainers/trainer-config"; import type { DexData, DexEntry } from "#types/dex-data"; import { RUN_HISTORY_LIMIT } from "#ui/run-history-ui-handler"; -import { executeIf, fixedInt, getEnumKeys, isLocal, NumberHolder, randInt, randSeedItem } from "#utils/common"; +import { executeIf, fixedInt, isLocal, NumberHolder, randInt, randSeedItem } from "#utils/common"; import { decrypt, encrypt } from "#utils/data"; +import { getEnumKeys } from "#utils/enums"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import { AES, enc } from "crypto-js"; import i18next from "i18next"; diff --git a/src/ui/daily-run-scoreboard.ts b/src/ui/daily-run-scoreboard.ts index c48f42d1ede..dcd45b40390 100644 --- a/src/ui/daily-run-scoreboard.ts +++ b/src/ui/daily-run-scoreboard.ts @@ -2,7 +2,8 @@ import { pokerogueApi } from "#api/pokerogue-api"; import { globalScene } from "#app/global-scene"; import { addTextObject, TextStyle } from "#ui/text"; import { addWindow, WindowVariant } from "#ui/ui-theme"; -import { executeIf, getEnumKeys } from "#utils/common"; +import { executeIf } from "#utils/common"; +import { getEnumKeys } from "#utils/enums"; import i18next from "i18next"; export interface RankingEntry { diff --git a/src/ui/egg-gacha-ui-handler.ts b/src/ui/egg-gacha-ui-handler.ts index c4f9a18b710..19d1efa75dd 100644 --- a/src/ui/egg-gacha-ui-handler.ts +++ b/src/ui/egg-gacha-ui-handler.ts @@ -11,7 +11,8 @@ import { getVoucherTypeIcon, VoucherType } from "#system/voucher"; import { MessageUiHandler } from "#ui/message-ui-handler"; import { addTextObject, getEggTierTextTint, getTextStyleOptions, TextStyle } from "#ui/text"; import { addWindow } from "#ui/ui-theme"; -import { fixedInt, getEnumValues, randSeedShuffle } from "#utils/common"; +import { fixedInt, randSeedShuffle } from "#utils/common"; +import { getEnumValues } from "#utils/enums"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; diff --git a/src/ui/menu-ui-handler.ts b/src/ui/menu-ui-handler.ts index bb3c3f7bc4e..4e45dfedcb3 100644 --- a/src/ui/menu-ui-handler.ts +++ b/src/ui/menu-ui-handler.ts @@ -13,8 +13,9 @@ import { BgmBar } from "#ui/bgm-bar"; import { MessageUiHandler } from "#ui/message-ui-handler"; import { addTextObject, getTextStyleOptions, TextStyle } from "#ui/text"; import { addWindow, WindowVariant } from "#ui/ui-theme"; -import { fixedInt, getEnumKeys, isLocal, sessionIdKey } from "#utils/common"; +import { fixedInt, isLocal, sessionIdKey } from "#utils/common"; import { getCookie } from "#utils/cookies"; +import { getEnumValues } from "#utils/enums"; import { isBeta } from "#utils/utility-vars"; import i18next from "i18next"; @@ -76,11 +77,9 @@ export class MenuUiHandler extends MessageUiHandler { { condition: bypassLogin, options: [MenuOptions.LOG_OUT] }, ]; - this.menuOptions = getEnumKeys(MenuOptions) - .map(m => Number.parseInt(MenuOptions[m]) as MenuOptions) - .filter(m => { - return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); - }); + this.menuOptions = getEnumValues(MenuOptions).filter(m => { + return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); + }); } setup(): void { @@ -131,11 +130,9 @@ export class MenuUiHandler extends MessageUiHandler { { condition: bypassLogin, options: [MenuOptions.LOG_OUT] }, ]; - this.menuOptions = getEnumKeys(MenuOptions) - .map(m => Number.parseInt(MenuOptions[m]) as MenuOptions) - .filter(m => { - return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); - }); + this.menuOptions = getEnumValues(MenuOptions).filter(m => { + return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); + }); this.optionSelectText = addTextObject( 0, @@ -511,11 +508,9 @@ export class MenuUiHandler extends MessageUiHandler { this.render(); super.show(args); - this.menuOptions = getEnumKeys(MenuOptions) - .map(m => Number.parseInt(MenuOptions[m]) as MenuOptions) - .filter(m => { - return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); - }); + this.menuOptions = getEnumValues(MenuOptions).filter(m => { + return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); + }); this.menuContainer.setVisible(true); this.setCursor(0); diff --git a/src/ui/pokedex-page-ui-handler.ts b/src/ui/pokedex-page-ui-handler.ts index 26cf7759c28..ff96aa55772 100644 --- a/src/ui/pokedex-page-ui-handler.ts +++ b/src/ui/pokedex-page-ui-handler.ts @@ -55,13 +55,13 @@ import { addBBCodeTextObject, addTextObject, getTextColor, getTextStyleOptions, import { addWindow } from "#ui/ui-theme"; import { BooleanHolder, - getEnumKeys, getLocalizedSpriteKey, isNullOrUndefined, padInt, rgbHexToRgba, toReadableString, } from "#utils/common"; +import { getEnumValues } from "#utils/enums"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import { argbFromRgba } from "@material/material-color-utilities"; import i18next from "i18next"; @@ -640,7 +640,7 @@ export class PokedexPageUiHandler extends MessageUiHandler { this.menuContainer.setVisible(false); - this.menuOptions = getEnumKeys(MenuOptions).map(m => Number.parseInt(MenuOptions[m]) as MenuOptions); + this.menuOptions = getEnumValues(MenuOptions); this.optionSelectText = addBBCodeTextObject( 0, @@ -744,7 +744,7 @@ export class PokedexPageUiHandler extends MessageUiHandler { this.starterAttributes = this.initStarterPrefs(); - this.menuOptions = getEnumKeys(MenuOptions).map(m => Number.parseInt(MenuOptions[m]) as MenuOptions); + this.menuOptions = getEnumValues(MenuOptions); this.menuContainer.setVisible(true); diff --git a/src/ui/summary-ui-handler.ts b/src/ui/summary-ui-handler.ts index fdd7479611a..da0a7f9a40f 100644 --- a/src/ui/summary-ui-handler.ts +++ b/src/ui/summary-ui-handler.ts @@ -29,7 +29,6 @@ import { UiHandler } from "#ui/ui-handler"; import { fixedInt, formatStat, - getEnumValues, getLocalizedSpriteKey, getShinyDescriptor, isNullOrUndefined, @@ -37,6 +36,7 @@ import { rgbHexToRgba, toReadableString, } from "#utils/common"; +import { getEnumValues } from "#utils/enums"; import { argbFromRgba } from "@material/material-color-utilities"; import i18next from "i18next"; diff --git a/src/utils/common.ts b/src/utils/common.ts index d46906840be..e9ba3acb5e5 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -8,11 +8,19 @@ export type nil = null | undefined; export const MissingTextureKey = "__MISSING"; +// TODO: Draft tests for these utility functions +// TODO: Break up this file +/** + * Convert a `snake_case` string in any capitalization (such as one from an enum reverse mapping) + * into a readable `Title Case` version. + * @param str - The snake case string to be converted. + * @returns The result of converting `str` into title case. + */ export function toReadableString(str: string): string { return str .replace(/_/g, " ") .split(" ") - .map(s => `${s.slice(0, 1)}${s.slice(1).toLowerCase()}`) + .map(s => capitalizeFirstLetter(s.toLowerCase())) .join(" "); } @@ -273,18 +281,6 @@ export function formatStat(stat: number, forHp = false): string { return formatLargeNumber(stat, forHp ? 100000 : 1000000); } -export function getEnumKeys(enumType: any): string[] { - return Object.values(enumType) - .filter(v => Number.isNaN(Number.parseInt(v!.toString()))) - .map(v => v!.toString()); -} - -export function getEnumValues(enumType: any): number[] { - return Object.values(enumType) - .filter(v => !Number.isNaN(Number.parseInt(v!.toString()))) - .map(v => Number.parseInt(v!.toString())); -} - export function executeIf(condition: boolean, promiseFunc: () => Promise): Promise { return condition ? promiseFunc() : new Promise(resolve => resolve(null)); } @@ -644,25 +640,3 @@ export function coerceArray(input: T): T extends any[] ? T : [T]; export function coerceArray(input: T): T | [T] { return Array.isArray(input) ? input : [input]; } - -/** - * Returns the name of the key that matches the enum [object] value. - * @param input - The enum [object] to check - * @param val - The value to get the key of - * @returns The name of the key with the specified value - * @example - * const thing = { - * one: 1, - * two: 2, - * } as const; - * console.log(enumValueToKey(thing, thing.two)); // output: "two" - * @throws An `Error` if an invalid enum value is passed to the function - */ -export function enumValueToKey>(input: T, val: T[keyof T]): keyof T { - for (const [key, value] of Object.entries(input)) { - if (val === value) { - return key as keyof T; - } - } - throw new Error(`Invalid value passed to \`enumValueToKey\`! Value: ${val}`); -} diff --git a/src/utils/enums.ts b/src/utils/enums.ts new file mode 100644 index 00000000000..98cb4272ee9 --- /dev/null +++ b/src/utils/enums.ts @@ -0,0 +1,74 @@ +import type { EnumOrObject, EnumValues, NormalEnum, TSNumericEnum } from "#app/@types/enum-types"; +import type { InferKeys } from "#app/@types/type-helpers"; + +/** + * Return the string keys of an Enum object, excluding reverse-mapped numbers. + * @param enumType - The numeric enum to retrieve keys for + * @returns An ordered array of all of `enumType`'s string keys + * @example + * enum fruit { + * apple = 1, + * banana = 2, + * cherry = 3, + * orange = 12, + * }; + * + * console.log(getEnumKeys(fruit)); // output: ["apple", "banana", "cherry", "orange"] + * @remarks + * To retrieve the keys of a {@linkcode NormalEnum}, use {@linkcode Object.keys} instead. + */ +export function getEnumKeys(enumType: TSNumericEnum): (keyof E)[] { + // All enum values are either normal numbers or reverse mapped strings, so we can retrieve the keys by filtering out numbers. + return Object.values(enumType).filter(v => typeof v === "string"); +} + +/** + * Return the numeric values of a numeric Enum object, excluding reverse-mapped strings. + * @param enumType - The enum object to retrieve keys for + * @returns An ordered array of all of `enumType`'s number values + * @example + * enum fruit { + * apple = 1, + * banana = 2, + * cherry = 3, + * orange = 12, + * }; + * + * console.log(getEnumValues(fruit)); // output: [1, 2, 3, 12] + * + * @remarks + * To retrieve the keys of a {@linkcode NormalEnum}, use {@linkcode Object.values} instead. + */ +// NB: This intentionally does not use `EnumValues` as using `E[keyof E]` leads to improved variable highlighting in IDEs. +export function getEnumValues(enumType: TSNumericEnum): E[keyof E][] { + return Object.values(enumType).filter(v => typeof v !== "string") as E[keyof E][]; +} + +/** + * Return the name of the key that matches the given Enum value. + * Can be used to emulate Typescript reverse mapping for `const object`s or string enums. + * @param object - The {@linkcode NormalEnum} to check + * @param val - The value to get the key of + * @returns The name of the key with the specified value + * @example + * const thing = { + * one: 1, + * two: 2, + * } as const; + * console.log(enumValueToKey(thing, 2)); // output: "two" + * @throws Error if an invalid enum value is passed to the function + * @remarks + * If multiple keys map to the same value, the first one (in insertion order) will be retrieved, + * but the return type will be the union of ALL their corresponding keys. + */ +export function enumValueToKey>( + object: NormalEnum, + val: V, +): InferKeys { + for (const [key, value] of Object.entries(object)) { + if (val === value) { + return key as InferKeys; + } + } + throw new Error(`Invalid value passed to \`enumValueToKey\`! Value: ${val}`); +} diff --git a/test/types/enum-types.test-d.ts b/test/types/enum-types.test-d.ts new file mode 100644 index 00000000000..396c479e85a --- /dev/null +++ b/test/types/enum-types.test-d.ts @@ -0,0 +1,104 @@ +import type { EnumOrObject, EnumValues, NormalEnum, TSNumericEnum } from "#app/@types/enum-types"; +import type { enumValueToKey, getEnumKeys, getEnumValues } from "#app/utils/enums"; +import { describe, expectTypeOf, it } from "vitest"; + +enum testEnumNum { + testN1 = 1, + testN2 = 2, +} + +enum testEnumString { + testS1 = "apple", + testS2 = "banana", +} + +const testObjNum = { testON1: 1, testON2: 2 } as const; + +const testObjString = { testOS1: "apple", testOS2: "banana" } as const; + +describe("Enum Type Helpers", () => { + describe("EnumValues", () => { + it("should go from enum object type to value type", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().branded.toEqualTypeOf<1 | 2>(); + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toMatchTypeOf<"apple" | "banana">(); + }); + + it("should produce union of const object values as type", () => { + expectTypeOf>().toEqualTypeOf<1 | 2>(); + + expectTypeOf>().toEqualTypeOf<"apple" | "banana">(); + }); + }); + + describe("TSNumericEnum", () => { + it("should match numeric enums", () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it("should not match string enums or const objects", () => { + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + }); + }); + + describe("NormalEnum", () => { + it("should match string enums or const objects", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + it("should not match numeric enums", () => { + expectTypeOf>().toBeNever(); + }); + }); + + describe("EnumOrObject", () => { + it("should match any enum or const object", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + + it("should not match an enum value union w/o typeof", () => { + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + }); + + it("should be equivalent to `TSNumericEnum | NormalEnum`", () => { + expectTypeOf().branded.toEqualTypeOf | NormalEnum>(); + }); + }); +}); + +describe("Enum Functions", () => { + describe("getEnumKeys", () => { + it("should retrieve keys of numeric enum", () => { + expectTypeOf>().returns.toEqualTypeOf<("testN1" | "testN2")[]>(); + }); + }); + + describe("getEnumValues", () => { + it("should retrieve values of numeric enum", () => { + expectTypeOf>().returns.branded.toEqualTypeOf<(1 | 2)[]>(); + }); + }); + + describe("enumValueToKey", () => { + it("should retrieve values for a given key", () => { + expectTypeOf< + typeof enumValueToKey + >().returns.toEqualTypeOf<"testS1">(); + expectTypeOf>().returns.toEqualTypeOf< + "testS1" | "testS2" + >(); + expectTypeOf>().returns.toEqualTypeOf<"testON1">(); + expectTypeOf>().returns.toEqualTypeOf<"testON1" | "testON2">(); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 3f7d640b727..706741c2774 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -34,6 +34,10 @@ export default defineProject(({ mode }) => ({ resources: "usable", }, }, + typecheck: { + tsconfig: "tsconfig.json", + include: ["./test/types/**/*.{test,spec}{-|.}d.ts"], + }, threads: false, trace: true, restoreMocks: true,