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,
}) satisfies {
// 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. */
@ -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<posTagInstanceMap[k], "tagType">;
};
/** 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 { 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;

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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<T> = Mutable<NonFunctionPropertiesRecursive<T>>;
describe("serializedPositionalTagMap", () => {
it("should contain representations of each tag's serialized form", () => {
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", () => {
it("should accept a union of all serialized tag forms", () => {
expectTypeOf<SerializedPositionalTag>().branded.toEqualTypeOf<
NonFunctionMutable<DelayedAttackTag> | NonFunctionMutable<WishTag>
NonFunctionPropertiesRecursive<DelayedAttackTag> | NonFunctionPropertiesRecursive<WishTag>
>();
});
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");
});
});
});