Added tests for the arena flyout + improved type inference on tags

This commit is contained in:
Bertie690 2025-08-14 20:46:51 -04:00
parent a821fc2f80
commit 73993f25c9
7 changed files with 156 additions and 80 deletions

View File

@ -42,7 +42,7 @@ const posTagConstructorMap = Object.freeze({
[PositionalTagType.WISH]: WishTag, [PositionalTagType.WISH]: WishTag,
}) satisfies { }) satisfies {
// NB: This `satisfies` block ensures that all tag types have corresponding entries in the map. // NB: This `satisfies` block ensures that all tag types have corresponding entries in the map.
[k in PositionalTagType]: Constructor<PositionalTag & { tagType: k }>; [k in PositionalTagType]: Constructor<PositionalTag & { readonly tagType: k }>;
}; };
/** Type mapping positional tag types to their constructors. */ /** 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. * Equivalent to their serialized representations.
* @interface
*/ */
export type serializedPosTagMap = { export type serializedPosTagMap = {
[k in PositionalTagType]: posTagParamMap[k] & { tagType: k }; [k in PositionalTagType]: posTagParamMap[k] & Pick<posTagInstanceMap[k], "tagType">;
}; };
/** Union type containing all serialized {@linkcode PositionalTag}s. */ /** Union type containing all serialized {@linkcode PositionalTag}s. */

View File

@ -7,7 +7,7 @@ import { allMoves } from "#data/data-lists";
import type { BattlerIndex } from "#enums/battler-index"; import type { BattlerIndex } from "#enums/battler-index";
import type { MoveId } from "#enums/move-id"; import type { MoveId } from "#enums/move-id";
import { MoveUseMode } from "#enums/move-use-mode"; 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 type { Pokemon } from "#field/pokemon";
import i18next from "i18next"; import i18next from "i18next";
@ -30,7 +30,7 @@ export interface PositionalTagBaseArgs {
/** /**
* The {@linkcode BattlerIndex} targeted by this effect. * 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. * Multiple tags of the same kind can stack with one another, provided they are affecting different targets.
*/ */
export abstract class PositionalTag implements PositionalTagBaseArgs { export abstract class PositionalTag implements PositionalTagBaseArgs {
/** This tag's {@linkcode PositionalTagType | type} */ /** This tag's {@linkcode PositionalTagType | type}. */
public abstract readonly tagType: PositionalTagType; public abstract readonly tagType: PositionalTagType;
// These arguments have to be public to implement the interface, but are functionally private // These arguments have to be public to implement the interface, but are functionally private
// outside this and the tag manager. // 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. * 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. */ /** 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. * triggering against a certain slot after the turn count has elapsed.
*/ */
export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs { 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 sourceMove: MoveId;
public readonly sourceId: number; 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 containing arguments used to construct a {@linkcode WishTag}. */
interface WishArgs extends PositionalTagBaseArgs { 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. */ /** 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. */ /** The name of the {@linkcode Pokemon} having created the tag. */
pokemonName: string; readonly pokemonName: string;
} }
/** /**
* Tag to implement {@linkcode MoveId.WISH | Wish}. * Tag to implement {@linkcode MoveId.WISH | Wish}.
*/ */
export class WishTag extends PositionalTag implements WishArgs { 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 pokemonName: string;
public readonly healHp: number; public readonly healHp: number;

View File

@ -35,4 +35,4 @@ export type ArenaEventType = ObjectValues<typeof ArenaEventType>;
{@linkcode TerrainType} {@linkcode TerrainType}
{@linkcode PositionalTag} {@linkcode PositionalTag}
{@linkcode ArenaTag} {@linkcode ArenaTag}
*/ */

View File

@ -12,7 +12,7 @@ import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index"; import { BattlerIndex } from "#enums/battler-index";
import { FieldPosition } from "#enums/field-position"; import { FieldPosition } from "#enums/field-position";
import { MoveId } from "#enums/move-id"; 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 { TextStyle } from "#enums/text-style";
import { WeatherType } from "#enums/weather-type"; import { WeatherType } from "#enums/weather-type";
import type { import type {
@ -85,38 +85,6 @@ interface PositionalTagInfo {
// #endregion interfaces // #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. * 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 * @param event - The {@linkcode ArenaTagAddedEvent} having been emitted
*/ */
private onArenaTagAdded(event: ArenaTagAddedEvent): void { private onArenaTagAdded(event: ArenaTagAddedEvent): void {
const name = localizeEffectName(ArenaTagType[event.tagType]); const name = this.localizeEffectName(ArenaTagType[event.tagType]);
// Ternary used to avoid unneeded find // Ternary used to avoid unneeded find
const existingTrapTag = const existingTrapTag =
event.trapLayers !== undefined event.trapLayers !== undefined
@ -389,7 +357,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container {
* @param event - The {@linkcode PositionalTagAddedEvent} having been emitted * @param event - The {@linkcode PositionalTagAddedEvent} having been emitted
*/ */
private onPositionalTagAdded(event: PositionalTagAddedEvent): void { private onPositionalTagAdded(event: PositionalTagAddedEvent): void {
const name = getPositionalTagDisplayName(event.tag); const name = this.getPositionalTagDisplayName(event.tag);
this.positionalTags.push({ this.positionalTags.push({
name, name,
@ -433,7 +401,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container {
} }
this.weatherInfo = { this.weatherInfo = {
name: localizeEffectName(WeatherType[event.weatherType]), name: this.localizeEffectName(WeatherType[event.weatherType]),
maxDuration: event.duration, maxDuration: event.duration,
duration: event.duration, duration: event.duration,
weatherType: event.weatherType, weatherType: event.weatherType,
@ -455,7 +423,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container {
} }
this.terrainInfo = { this.terrainInfo = {
name: localizeEffectName(TerrainType[event.terrainType]), name: this.localizeEffectName(TerrainType[event.terrainType]),
maxDuration: event.duration, maxDuration: event.duration,
duration: event.duration, duration: event.duration,
terrainType: event.terrainType, terrainType: event.terrainType,
@ -549,7 +517,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container {
} }
const targetPos = battlerIndexToFieldPosition(info.targetIndex); const targetPos = battlerIndexToFieldPosition(info.targetIndex);
const posText = localizeEffectName(FieldPosition[targetPos]); const posText = this.localizeEffectName(FieldPosition[targetPos]);
// Ex: "Future Sight (Center, 2)" // Ex: "Future Sight (Center, 2)"
return `${info.name} (${posText}, ${info.duration})\n`; return `${info.name} (${posText}, ${info.duration})\n`;
@ -606,6 +574,39 @@ export class ArenaFlyout extends Phaser.GameObjects.Container {
} }
// # endregion Text display functions // # 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. * @returns The resultant field position.
*/ */
function battlerIndexToFieldPosition(index: BattlerIndex): FieldPosition { function battlerIndexToFieldPosition(index: BattlerIndex): FieldPosition {
let pos: FieldPosition; const pos = globalScene.getField()[index]?.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;
}
return pos; return pos;
} }

View File

@ -1,13 +1,12 @@
import i18next, { type ParseKeys } from "i18next"; import i18next, { type ParseKeys } from "i18next";
import { vi } from "vitest"; import { type MockInstance, vi } from "vitest";
/** /**
* Sets up the i18next mock. * Mock i18next's {@linkcode t} function to only produce the raw key.
* Includes a i18next.t mocked implementation only returning the raw key (`(key) => 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); return vi.spyOn(i18next, "t").mockImplementation((key: ParseKeys) => key);
} }

View File

@ -1,25 +1,24 @@
import type { SerializedPositionalTag, serializedPosTagMap } from "#data/positional-tags/load-positional-tag"; import type { SerializedPositionalTag, serializedPosTagMap } from "#data/positional-tags/load-positional-tag";
import type { DelayedAttackTag, WishTag } from "#data/positional-tags/positional-tag"; import type { DelayedAttackTag, WishTag } from "#data/positional-tags/positional-tag";
import type { PositionalTagType } from "#enums/positional-tag-type"; 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"; import { describe, expectTypeOf, it } from "vitest";
// Needed to get around properties being readonly in certain classes
type NonFunctionMutable<T> = Mutable<NonFunctionPropertiesRecursive<T>>;
describe("serializedPositionalTagMap", () => { describe("serializedPositionalTagMap", () => {
it("should contain representations of each tag's serialized form", () => { it("should contain representations of each tag's serialized form", () => {
expectTypeOf<serializedPosTagMap[PositionalTagType.DELAYED_ATTACK]>().branded.toEqualTypeOf< expectTypeOf<serializedPosTagMap[PositionalTagType.DELAYED_ATTACK]>().branded.toEqualTypeOf<
NonFunctionMutable<DelayedAttackTag> NonFunctionPropertiesRecursive<DelayedAttackTag>
>();
expectTypeOf<serializedPosTagMap[PositionalTagType.WISH]>().branded.toEqualTypeOf<
NonFunctionPropertiesRecursive<WishTag>
>(); >();
expectTypeOf<serializedPosTagMap[PositionalTagType.WISH]>().branded.toEqualTypeOf<NonFunctionMutable<WishTag>>();
}); });
}); });
describe("SerializedPositionalTag", () => { describe("SerializedPositionalTag", () => {
it("should accept a union of all serialized tag forms", () => { it("should accept a union of all serialized tag forms", () => {
expectTypeOf<SerializedPositionalTag>().branded.toEqualTypeOf< expectTypeOf<SerializedPositionalTag>().branded.toEqualTypeOf<
NonFunctionMutable<DelayedAttackTag> | NonFunctionMutable<WishTag> NonFunctionPropertiesRecursive<DelayedAttackTag> | NonFunctionPropertiesRecursive<WishTag>
>(); >();
}); });
it("should accept a union of all unserialized tag forms", () => { it("should accept a union of all unserialized tag forms", () => {

View File

@ -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<posTagInfo, "duration" | "name" | "targetIndex">; 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");
});
});
});