diff --git a/src/data/positional-tags/load-positional-tag.ts b/src/data/positional-tags/load-positional-tag.ts index 97ad74971c2..7ae396dc8f1 100644 --- a/src/data/positional-tags/load-positional-tag.ts +++ b/src/data/positional-tags/load-positional-tag.ts @@ -42,7 +42,7 @@ const posTagConstructorMap = Object.freeze({ [PositionalTagType.WISH]: WishTag, }) satisfies { // NB: This `satisfies` block ensures that all tag types have corresponding entries in the map. - [k in PositionalTagType]: Constructor; + [k in PositionalTagType]: Constructor; }; /** Type mapping positional tag types to their constructors. */ @@ -59,11 +59,12 @@ type posTagParamMap = { }; /** - * Type mapping all positional tag types to their constructors' parameters, alongside the `tagType` selector. + * Type mapping all positional tag types to their constructors' parameters, alongside the `tagType` selector. \ * Equivalent to their serialized representations. + * @interface */ export type serializedPosTagMap = { - [k in PositionalTagType]: posTagParamMap[k] & { tagType: k }; + [k in PositionalTagType]: posTagParamMap[k] & Pick; }; /** Union type containing all serialized {@linkcode PositionalTag}s. */ diff --git a/src/data/positional-tags/positional-tag.ts b/src/data/positional-tags/positional-tag.ts index 77ca6f0e9eb..9f3af265c26 100644 --- a/src/data/positional-tags/positional-tag.ts +++ b/src/data/positional-tags/positional-tag.ts @@ -7,7 +7,7 @@ import { allMoves } from "#data/data-lists"; import type { BattlerIndex } from "#enums/battler-index"; import type { MoveId } from "#enums/move-id"; import { MoveUseMode } from "#enums/move-use-mode"; -import { PositionalTagType } from "#enums/positional-tag-type"; +import type { PositionalTagType } from "#enums/positional-tag-type"; import type { Pokemon } from "#field/pokemon"; import i18next from "i18next"; @@ -30,7 +30,7 @@ export interface PositionalTagBaseArgs { /** * The {@linkcode BattlerIndex} targeted by this effect. */ - targetIndex: BattlerIndex; + readonly targetIndex: BattlerIndex; } /** @@ -39,7 +39,7 @@ export interface PositionalTagBaseArgs { * Multiple tags of the same kind can stack with one another, provided they are affecting different targets. */ export abstract class PositionalTag implements PositionalTagBaseArgs { - /** This tag's {@linkcode PositionalTagType | type} */ + /** This tag's {@linkcode PositionalTagType | type}. */ public abstract readonly tagType: PositionalTagType; // These arguments have to be public to implement the interface, but are functionally private // outside this and the tag manager. @@ -76,9 +76,9 @@ interface DelayedAttackArgs extends PositionalTagBaseArgs { /** * The {@linkcode Pokemon.id | PID} of the {@linkcode Pokemon} having created this effect. */ - sourceId: number; + readonly sourceId: number; /** The {@linkcode MoveId} that created this attack. */ - sourceMove: MoveId; + readonly sourceMove: MoveId; } /** @@ -87,7 +87,7 @@ interface DelayedAttackArgs extends PositionalTagBaseArgs { * triggering against a certain slot after the turn count has elapsed. */ export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs { - public override readonly tagType = PositionalTagType.DELAYED_ATTACK; + public declare readonly tagType: PositionalTagType.DELAYED_ATTACK; public readonly sourceMove: MoveId; public readonly sourceId: number; @@ -133,16 +133,16 @@ export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs /** Interface containing arguments used to construct a {@linkcode WishTag}. */ interface WishArgs extends PositionalTagBaseArgs { /** The amount of {@linkcode Stat.HP | HP} to heal; set to 50% of the user's max HP during move usage. */ - healHp: number; + readonly healHp: number; /** The name of the {@linkcode Pokemon} having created the tag. */ - pokemonName: string; + readonly pokemonName: string; } /** * Tag to implement {@linkcode MoveId.WISH | Wish}. */ export class WishTag extends PositionalTag implements WishArgs { - public override readonly tagType = PositionalTagType.WISH; + public declare readonly tagType: PositionalTagType.WISH; public readonly pokemonName: string; public readonly healHp: number; diff --git a/src/enums/arena-event-type.ts b/src/enums/arena-event-type.ts index 534bc7e756d..c35828e9470 100644 --- a/src/enums/arena-event-type.ts +++ b/src/enums/arena-event-type.ts @@ -35,4 +35,4 @@ export type ArenaEventType = ObjectValues; {@linkcode TerrainType} {@linkcode PositionalTag} {@linkcode ArenaTag} -*/ \ No newline at end of file +*/ diff --git a/src/ui/arena-flyout.ts b/src/ui/arena-flyout.ts index 2337ed48d14..4647eda8cbe 100644 --- a/src/ui/arena-flyout.ts +++ b/src/ui/arena-flyout.ts @@ -12,7 +12,7 @@ import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerIndex } from "#enums/battler-index"; import { FieldPosition } from "#enums/field-position"; import { MoveId } from "#enums/move-id"; -import { PositionalTagType } from "#enums/positional-tag-type"; +import type { PositionalTagType } from "#enums/positional-tag-type"; import { TextStyle } from "#enums/text-style"; import { WeatherType } from "#enums/weather-type"; import type { @@ -85,38 +85,6 @@ interface PositionalTagInfo { // #endregion interfaces -// #region String functions - -/** - * Return the localized text for a given effect. - * @param text - The raw text of the effect; assumed to be in `UPPER_SNAKE_CASE` from a reverse mapping. - * @returns The localized text for the effect. - */ -export function localizeEffectName(text: string): string { - const effectName = toCamelCase(text); - const i18nKey = `arenaFlyout:${effectName}`; - const resultName = i18next.t(i18nKey); - return resultName; -} - -/** - * Return the localized name of a given {@linkcode PositionalTag}. - * @param tag - The raw serialized data for the given tag - * @returns The localized text to be displayed on-screen. - * @package - */ -export function getPositionalTagDisplayName(tag: SerializedPositionalTag): string { - let tagName: string; - if ("sourceMove" in tag) { - // Delayed attacks will use the source move's name; other effects rely on type - tagName = MoveId[tag.sourceMove]; - } else { - tagName = PositionalTagType[tag.tagType]; - } - - return localizeEffectName(tagName); -} - /** * Class to display and update the on-screen arena flyout. */ @@ -330,7 +298,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { * @param event - The {@linkcode ArenaTagAddedEvent} having been emitted */ private onArenaTagAdded(event: ArenaTagAddedEvent): void { - const name = localizeEffectName(ArenaTagType[event.tagType]); + const name = this.localizeEffectName(ArenaTagType[event.tagType]); // Ternary used to avoid unneeded find const existingTrapTag = event.trapLayers !== undefined @@ -389,7 +357,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { * @param event - The {@linkcode PositionalTagAddedEvent} having been emitted */ private onPositionalTagAdded(event: PositionalTagAddedEvent): void { - const name = getPositionalTagDisplayName(event.tag); + const name = this.getPositionalTagDisplayName(event.tag); this.positionalTags.push({ name, @@ -433,7 +401,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { } this.weatherInfo = { - name: localizeEffectName(WeatherType[event.weatherType]), + name: this.localizeEffectName(WeatherType[event.weatherType]), maxDuration: event.duration, duration: event.duration, weatherType: event.weatherType, @@ -455,7 +423,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { } this.terrainInfo = { - name: localizeEffectName(TerrainType[event.terrainType]), + name: this.localizeEffectName(TerrainType[event.terrainType]), maxDuration: event.duration, duration: event.duration, terrainType: event.terrainType, @@ -549,7 +517,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { } const targetPos = battlerIndexToFieldPosition(info.targetIndex); - const posText = localizeEffectName(FieldPosition[targetPos]); + const posText = this.localizeEffectName(FieldPosition[targetPos]); // Ex: "Future Sight (Center, 2)" return `${info.name} (${posText}, ${info.duration})\n`; @@ -606,6 +574,39 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { } // # endregion Text display functions + + // #region Utilities + + /** + * Return the localized text for a given effect. + * @param text - The raw text of the effect; assumed to be in `UPPER_SNAKE_CASE` from a reverse mapping. + * @returns The localized text for the effect. + */ + private localizeEffectName(text: string): string { + const effectName = toCamelCase(text); + const i18nKey = `arenaFlyout:${effectName}`; + const resultName = i18next.t(i18nKey); + return resultName; + } + + /** + * Return the localized name of a given {@linkcode PositionalTag}. + * @param tag - The raw serialized data for the given tag + * @returns The localized text to be displayed on-screen. + */ + private getPositionalTagDisplayName(tag: SerializedPositionalTag): string { + let tagName: string; + if ("sourceMove" in tag) { + // Delayed attacks will use the source move's name; other effects use type directly + tagName = MoveId[tag.sourceMove]; + } else { + tagName = tag.tagType; + } + + return this.localizeEffectName(tagName); + } + + // #endregion Utility emthods } /** @@ -614,22 +615,6 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { * @returns The resultant field position. */ function battlerIndexToFieldPosition(index: BattlerIndex): FieldPosition { - let pos: FieldPosition; - switch (index) { - case BattlerIndex.ATTACKER: - throw new Error("Cannot convert BattlerIndex.ATTACKER to a field position!"); - case BattlerIndex.PLAYER: - case BattlerIndex.ENEMY: - pos = FieldPosition.LEFT; - break; - case BattlerIndex.PLAYER_2: - case BattlerIndex.ENEMY_2: - pos = FieldPosition.RIGHT; - break; - } - // In single battles, left positions become center - if (!globalScene.currentBattle.double && pos === FieldPosition.LEFT) { - pos = FieldPosition.CENTER; - } + const pos = globalScene.getField()[index]?.fieldPosition; return pos; } diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 40e4bbe8775..7e095a323ae 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -1,13 +1,12 @@ import i18next, { type ParseKeys } from "i18next"; -import { vi } from "vitest"; +import { type MockInstance, vi } from "vitest"; /** - * Sets up the i18next mock. - * Includes a i18next.t mocked implementation only returning the raw key (`(key) => key`) + * Mock i18next's {@linkcode t} function to only produce the raw key. * - * @returns A spy/mock of i18next + * @returns A {@linkcode MockInstance} for `i18next.t` */ -export function mockI18next() { +export function mockI18next(): MockInstance<(typeof i18next)["t"]> { return vi.spyOn(i18next, "t").mockImplementation((key: ParseKeys) => key); } diff --git a/test/types/positional-tags.test-d.ts b/test/types/positional-tags.test-d.ts index a75cc291764..4965a0208ac 100644 --- a/test/types/positional-tags.test-d.ts +++ b/test/types/positional-tags.test-d.ts @@ -1,25 +1,24 @@ import type { SerializedPositionalTag, serializedPosTagMap } from "#data/positional-tags/load-positional-tag"; import type { DelayedAttackTag, WishTag } from "#data/positional-tags/positional-tag"; import type { PositionalTagType } from "#enums/positional-tag-type"; -import type { Mutable, NonFunctionPropertiesRecursive } from "#types/type-helpers"; +import type { NonFunctionPropertiesRecursive } from "#types/type-helpers"; import { describe, expectTypeOf, it } from "vitest"; -// Needed to get around properties being readonly in certain classes -type NonFunctionMutable = Mutable>; - describe("serializedPositionalTagMap", () => { it("should contain representations of each tag's serialized form", () => { expectTypeOf().branded.toEqualTypeOf< - NonFunctionMutable + NonFunctionPropertiesRecursive + >(); + expectTypeOf().branded.toEqualTypeOf< + NonFunctionPropertiesRecursive >(); - expectTypeOf().branded.toEqualTypeOf>(); }); }); describe("SerializedPositionalTag", () => { it("should accept a union of all serialized tag forms", () => { expectTypeOf().branded.toEqualTypeOf< - NonFunctionMutable | NonFunctionMutable + NonFunctionPropertiesRecursive | NonFunctionPropertiesRecursive >(); }); it("should accept a union of all unserialized tag forms", () => { diff --git a/test/ui/flyouts/arena-flyout.test.ts b/test/ui/flyouts/arena-flyout.test.ts new file mode 100644 index 00000000000..1ca970d82f1 --- /dev/null +++ b/test/ui/flyouts/arena-flyout.test.ts @@ -0,0 +1,92 @@ +import type { SerializedPositionalTag } from "#data/positional-tags/load-positional-tag"; +import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; +import { MoveId } from "#enums/move-id"; +import { PositionalTagType } from "#enums/positional-tag-type"; +import { SpeciesId } from "#enums/species-id"; +import { GameManager } from "#test/test-utils/game-manager"; +import { mockI18next } from "#test/test-utils/test-utils"; +import type { ArenaFlyout } from "#ui/arena-flyout"; +import type i18next from "i18next"; +import Phaser from "phaser"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +describe("UI - Arena Flyout", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let flyout: ArenaFlyout; + let tSpy: MockInstance<(typeof i18next)["t"]>; + + beforeAll(async () => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + + game = new GameManager(phaserGame); + game.override + .ability(AbilityId.BALL_FETCH) + .battleStyle("double") + .criticalHits(false) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + flyout = game.scene.arenaFlyout; + }); + + beforeEach(() => { + // Reset i18n mock before each test + tSpy = mockI18next(); + }); + + afterAll(() => { + game.phaseInterceptor.restoreOg(); + }); + + describe("localizeEffectName", () => { + it("should retrieve locales from an effect name", () => { + const name = flyout["localizeEffectName"]("STEALTH_ROCK"); + expect(name).toBe("arenaFlyout:stealthRock"); + expect(tSpy).toHaveBeenCalledExactlyOnceWith("arenaFlyout:stealthRock"); + }); + }); + + // Helper type to get around unexportedness + type posTagInfo = (typeof flyout)["positionalTags"][number]; + + describe("getPositionalTagDisplayName", () => { + it.each([ + { tag: { tagType: PositionalTagType.WISH }, name: "arenaFlyout:wish" }, + { + tag: { sourceMove: MoveId.FUTURE_SIGHT, tagType: PositionalTagType.DELAYED_ATTACK }, + name: "arenaFlyout:futureSight", + }, + { + tag: { sourceMove: MoveId.DOOM_DESIRE, tagType: PositionalTagType.DELAYED_ATTACK }, + name: "arenaFlyout:doomDesire", + }, + ])("should get the name of a Positional Tag", ({ tag, name }) => { + const got = flyout["getPositionalTagDisplayName"](tag as SerializedPositionalTag); + expect(got).toBe(name); + }); + }); + + describe("getPosTagText", () => { + it.each<{ tag: Pick; output: string; double?: boolean }>([ + { tag: { duration: 2, name: "Wish", targetIndex: BattlerIndex.PLAYER }, output: "Wish (2)" }, + { + tag: { duration: 1, name: "Future Sight", targetIndex: BattlerIndex.ENEMY_2 }, + double: true, + output: "Future Sight (arenaFlyout:right, 1)", + }, + ])("should produce the correct text", ({ tag, output, double = false }) => { + vi.spyOn(game.scene.currentBattle, "double", "get").mockReturnValue(double); + const text = flyout["getPosTagText"](tag as posTagInfo); + expect(text).toBe(output + "\n"); + }); + }); +});