From 5d1e13139c194a86df0cb9dfda631a98a737a767 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Fri, 1 Aug 2025 18:34:27 -0400 Subject: [PATCH] rest of phase interceptor changes --- scripts/create-test/boilerplates/default.ts | 6 +- src/battle-scene.ts | 46 +- src/phase-manager.ts | 28 +- test/test-utils/error-interceptor.ts | 49 -- test/test-utils/game-manager-utils.ts | 11 - test/test-utils/game-wrapper.ts | 12 +- test/test-utils/helpers/prompt-handler.ts | 156 +++++ test/test-utils/interval-helper.ts | 43 ++ test/test-utils/mocks/mock-phase.ts | 11 + test/test-utils/phase-interceptor.ts | 644 +++++------------- .../tests/helpers/prompt-handler.test.ts | 153 +++++ .../phase-interceptor/integration.test.ts | 62 ++ .../tests/phase-interceptor/unit.test.ts | 150 ++++ test/test-utils/tests/timeout-reset.test.ts | 25 + test/vitest.setup.ts | 19 +- 15 files changed, 864 insertions(+), 551 deletions(-) delete mode 100644 test/test-utils/error-interceptor.ts create mode 100644 test/test-utils/helpers/prompt-handler.ts create mode 100644 test/test-utils/interval-helper.ts create mode 100644 test/test-utils/mocks/mock-phase.ts create mode 100644 test/test-utils/tests/helpers/prompt-handler.test.ts create mode 100644 test/test-utils/tests/phase-interceptor/integration.test.ts create mode 100644 test/test-utils/tests/phase-interceptor/unit.test.ts create mode 100644 test/test-utils/tests/timeout-reset.test.ts diff --git a/scripts/create-test/boilerplates/default.ts b/scripts/create-test/boilerplates/default.ts index fa914b150c2..b405fe9412e 100644 --- a/scripts/create-test/boilerplates/default.ts +++ b/scripts/create-test/boilerplates/default.ts @@ -3,7 +3,7 @@ import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("{{description}}", () => { let phaserGame: Phaser.Game; @@ -15,10 +15,6 @@ describe("{{description}}", () => { }); }); - afterEach(() => { - game.phaseInterceptor.restoreOg(); - }); - beforeEach(() => { game = new GameManager(phaserGame); game.override diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 1864de2c6f4..aaefcd3a6e2 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -380,12 +380,28 @@ export class BattleScene extends SceneBase { }; } - populateAnims(); + /** + * These moves serve as fallback animations for other moves without loaded animations, and + * must be loaded prior to game start. + */ + const defaultMoves = [MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE]; - await this.initVariantData(); + await Promise.all([ + populateAnims(), + this.initVariantData(), + initCommonAnims().then(() => loadCommonAnimAssets(true)), + Promise.all(defaultMoves.map(m => initMoveAnim(m))).then(() => loadMoveAnimAssets(defaultMoves, true)), + this.initStarterColors(), + ]).catch(reason => { + throw new Error(`Unexpected error during BattleScene preLoad!\nReason: ${reason}`); + }); } - create() { + /** + * Create game objects with loaded assets. + * Called by Phaser on new game start. + */ + create(): void { this.scene.remove(LoadingScene.KEY); initGameSpeed.apply(this); this.inputController = new InputsController(); @@ -410,6 +426,7 @@ export class BattleScene extends SceneBase { this.ui?.update(); } + // TODO: Split this up into multiple sub-methods launchBattle() { this.arenaBg = this.add.sprite(0, 0, "plains_bg"); this.arenaBg.setName("sprite-arena-bg"); @@ -584,8 +601,6 @@ export class BattleScene extends SceneBase { this.party = []; - const loadPokemonAssets = []; - this.arenaPlayer = new ArenaBase(true); this.arenaPlayer.setName("arena-player"); this.arenaPlayerTransition = new ArenaBase(true); @@ -600,6 +615,8 @@ export class BattleScene extends SceneBase { this.arenaNextEnemy.setVisible(false); for (const a of [this.arenaPlayer, this.arenaPlayerTransition, this.arenaEnemy, this.arenaNextEnemy]) { + // TODO: This seems questionable - we just initialized the arena sprites and then have to manually check if they're a sprite? + // This is likely the result of either extreme laziness or confusion if (a instanceof Phaser.GameObjects.Sprite) { a.setOrigin(0, 0); } @@ -640,26 +657,16 @@ export class BattleScene extends SceneBase { this.reset(false, false, true); + // Initialize UI-related aspects and then start the login phase. const ui = new UI(); this.uiContainer.add(ui); - this.ui = ui; - ui.setup(); - const defaultMoves = [MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE]; + this.phaseManager.pushNew("LoginPhase"); + this.phaseManager.pushNew("TitlePhase"); - Promise.all([ - Promise.all(loadPokemonAssets), - initCommonAnims().then(() => loadCommonAnimAssets(true)), - Promise.all( - [MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE].map(m => initMoveAnim(m)), - ).then(() => loadMoveAnimAssets(defaultMoves, true)), - this.initStarterColors(), - ]).then(() => { - this.phaseManager.toTitleScreen(true); - this.phaseManager.shiftPhase(); - }); + this.phaseManager.shiftPhase(); } initSession(): void { @@ -1153,6 +1160,7 @@ export class BattleScene extends SceneBase { return this.currentBattle?.randSeedInt(range, min); } + // TODO: Break up function - this does far too much in 1 sitting reset(clearScene = false, clearData = false, reloadI18n = false): void { if (clearData) { this.gameData = new GameData(); diff --git a/src/phase-manager.ts b/src/phase-manager.ts index aa01a0ffc10..216e9805cf3 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -370,6 +370,9 @@ export class PhaseManager { } this.currentPhase = this.phaseQueue.shift() ?? null; + if (!this.currentPhase) { + throw new Error("No phases in queue; aborting"); + } const unactivatedConditionalPhases: [() => boolean, Phase][] = []; // Check if there are any conditional phases queued @@ -389,12 +392,26 @@ export class PhaseManager { } this.conditionalQueue.push(...unactivatedConditionalPhases); - if (this.currentPhase) { - console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;"); - this.currentPhase.start(); - } + this.startCurrentPhase(); } + /** + * Helper method to start and log the current phase. + * + * @remarks + * This is disabled during tests by `phase-interceptor.ts` to allow for pausing execution at specific phases. + * As such, **do not remove or split this method** as it will break integration tests. + */ + private startCurrentPhase(): void { + if (!this.currentPhase) { + console.warn("Trying to start null phase!"); + return; + } + console.log(`%cStart Phase ${this.currentPhase.phaseName}`, "color:green;"); + this.currentPhase.start(); + } + + // TODO: Review if we can remove this overridePhase(phase: Phase): boolean { if (this.standbyPhase) { return false; @@ -402,8 +419,7 @@ export class PhaseManager { this.standbyPhase = this.currentPhase; this.currentPhase = phase; - console.log(`%cStart Phase ${phase.constructor.name}`, "color:green;"); - phase.start(); + this.startCurrentPhase(); return true; } diff --git a/test/test-utils/error-interceptor.ts b/test/test-utils/error-interceptor.ts deleted file mode 100644 index c253561a71c..00000000000 --- a/test/test-utils/error-interceptor.ts +++ /dev/null @@ -1,49 +0,0 @@ -export class ErrorInterceptor { - private static instance: ErrorInterceptor; - public running; - - constructor() { - this.running = []; - } - - public static getInstance(): ErrorInterceptor { - if (!ErrorInterceptor.instance) { - ErrorInterceptor.instance = new ErrorInterceptor(); - } - return ErrorInterceptor.instance; - } - - clear() { - this.running = []; - } - - add(obj) { - this.running.push(obj); - } - - remove(obj) { - const index = this.running.indexOf(obj); - if (index !== -1) { - this.running.splice(index, 1); - } - } -} - -process.on("uncaughtException", error => { - console.log(error); - const toStop = ErrorInterceptor.getInstance().running; - for (const elm of toStop) { - elm.rejectAll(error); - } - global.testFailed = true; -}); - -// Global error handler for unhandled promise rejections -process.on("unhandledRejection", (reason, _promise) => { - console.log(reason); - const toStop = ErrorInterceptor.getInstance().running; - for (const elm of toStop) { - elm.rejectAll(reason); - } - global.testFailed = true; -}); diff --git a/test/test-utils/game-manager-utils.ts b/test/test-utils/game-manager-utils.ts index db758cfe64d..7bb8ea57469 100644 --- a/test/test-utils/game-manager-utils.ts +++ b/test/test-utils/game-manager-utils.ts @@ -87,17 +87,6 @@ function getTestRunStarters(seed: string, species?: SpeciesId[]): Starter[] { return starters; } -export function waitUntil(truth): Promise { - return new Promise(resolve => { - const interval = setInterval(() => { - if (truth()) { - clearInterval(interval); - resolve(true); - } - }, 1000); - }); -} - /** * Useful for populating party, wave index, etc. without having to spin up and run through an entire EncounterPhase */ diff --git a/test/test-utils/game-wrapper.ts b/test/test-utils/game-wrapper.ts index 1a906bf8492..9f59fadc646 100644 --- a/test/test-utils/game-wrapper.ts +++ b/test/test-utils/game-wrapper.ts @@ -47,12 +47,14 @@ export class GameWrapper { public scene: BattleScene; constructor(phaserGame: Phaser.Game, bypassLogin: boolean) { + // TODO: Figure out how to actually set RNG states correctly Phaser.Math.RND.sow(["test"]); // vi.spyOn(Utils, "apiFetch", "get").mockReturnValue(fetch); if (bypassLogin) { vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(true); } this.game = phaserGame; + // TODO: Move these mocks elsewhere MoveAnim.prototype.getAnim = () => ({ frames: {}, }); @@ -71,10 +73,16 @@ export class GameWrapper { PokedexMonContainer.prototype.remove = MockContainer.prototype.remove; } - setScene(scene: BattleScene) { + /** + * Initialize the given {@linkcode BattleScene} and override various properties to avoid crashes with headless games. + * @param scene - The {@linkcode BattleScene} to initialize. + * @returns A Promise that resolves once the initialization process has completed. + */ + // TODO: is asset loading & method overriding actually needed for a headless renderer? + async setScene(scene: BattleScene): Promise { this.scene = scene; this.injectMandatory(); - this.scene.preload?.(); + this.scene.preload(); this.scene.create(); } diff --git a/test/test-utils/helpers/prompt-handler.ts b/test/test-utils/helpers/prompt-handler.ts new file mode 100644 index 00000000000..355925d88fc --- /dev/null +++ b/test/test-utils/helpers/prompt-handler.ts @@ -0,0 +1,156 @@ +import type { AwaitableUiHandler } from "#app/ui/awaitable-ui-handler"; +import { UiMode } from "#enums/ui-mode"; +import type { GameManager } from "#test/test-utils/game-manager"; +import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper"; +import { setTempInterval } from "#test/test-utils/interval-helper"; +import type { PhaseString } from "#types/phase-types"; +import chalk from "chalk"; +import { type MockInstance, vi } from "vitest"; + +interface UIPrompt { + /** The {@linkcode PhaseString | name} of the Phase during which to execute the callback. */ + phaseTarget: PhaseString; + /** The {@linkcode UIMode} to wait for. */ + mode: UiMode; + /** The callback function to execute. */ + callback: () => void; + /** + * An optional callback function to determine if the prompt has expired and should be removed. + * Expired prompts are removed upon the next UI mode change without executing their callback. + */ + expireFn?: () => boolean; + /** + * If `true`, restricts the prompt to only activate when the current {@linkcode AwaitableUiHandler} is waiting for input. + * @defaultValue `false` + */ + awaitingActionInput: boolean; +} + +/** + * Array of phases that hang whiile waiting for player input. + * Changing UI modes during these phases will halt the phase interceptor. + * @todo This is an extremely unintuitive solution that only works on a select few phases + */ +const endBySetMode: ReadonlyArray = [ + "CommandPhase", + "TitlePhase", + "SelectGenderPhase", + "SelectStarterPhase", + "SelectModifierPhase", + "MysteryEncounterPhase", + "PostMysteryEncounterPhase", +]; + +/** Helper class to handle executing prompts upon UI mode changes. */ +export class PromptHandler extends GameManagerHelper { + /** An array of {@linkcode UIPrompt | prompts} with associated callbacks. */ + private prompts: UIPrompt[] = []; + /** The original `setModeInternal` function, stored for use in {@linkcode setMode}. */ + private originalSetModeInternal: (typeof this.game.scene.ui)["setModeInternal"]; + + constructor(game: GameManager) { + super(game); + this.originalSetModeInternal = this.game.scene.ui["setModeInternal"]; + // `any` assertion needed as we are mocking private property + ( + vi.spyOn(this.game.scene.ui as any, "setModeInternal") as MockInstance< + (typeof this.game.scene.ui)["setModeInternal"] + > + ).mockImplementation((...args) => this.setMode(args)); + + // Set an interval to repeatedly check the current prompt. + // TODO: Ideally we would find a way NOT for this to use a prompt check... + setTempInterval(() => this.doPromptCheck()); + } + + /** + * Helper method to wrap UI mode changing. + * @param args - Arguments being passed to the original method + * @returns The original return value. + */ + private setMode(args: Parameters) { + const mode = args[0]; + + this.doLog(`UI mode changed to ${UiMode[mode]} (=${mode})!`); + const ret = this.originalSetModeInternal.apply(this.game.scene.ui, args) as ReturnType< + typeof this.originalSetModeInternal + >; + + const currentPhase = this.game.scene.phaseManager.getCurrentPhase()?.phaseName!; + if (endBySetMode.includes(currentPhase)) { + this.game.phaseInterceptor.checkMode(); + } + return ret; + } + + /** + * Method to perform prompt handling every so often. + * @param uiMode - The {@linkcode UiMode} being set + */ + private doPromptCheck(): void { + if (this.prompts.length === 0) { + return; + } + + const prompt = this.prompts[0]; + this.doLog("Checking prompts..."); + + // remove expired prompts + if (prompt.expireFn?.()) { + this.prompts.shift(); + return; + } + + const currentPhase = this.game.scene.phaseManager.getCurrentPhase()?.phaseName; + const currentHandler = this.game.scene.ui.getHandler(); + const mode = this.game.scene.ui.getMode(); + + // If the current mode, phase, and handler match the expected values, execute the callback and continue. + // If not, leave it there. + if ( + mode === prompt.mode && + currentPhase === prompt.phaseTarget && + currentHandler.active && + !(prompt.awaitingActionInput && !(currentHandler as AwaitableUiHandler)["awaitingActionInput"]) + ) { + prompt.callback(); + this.prompts.shift(); + } + } + + /** + * Queue a callback to be executed on the next UI mode change. + * This can be used to (among other things) simulate inputs or run callbacks mid-phase. + * @param phaseTarget - The {@linkcode PhaseString | name} of the Phase during which the callback will be executed + * @param mode - The {@linkcode UiMode} to wait for + * @param callback - The callback function to execute + * @param expireFn - Optional function to determine if the prompt has expired + * @param awaitingActionInput - If `true`, restricts the prompt to only activate when the current {@linkcode AwaitableUiHandler} is waiting for input; default `false` + * @remarks + * If multiple prompts are queued up in succession, each will be checked in turn **until the first prompt that neither expires nor matches**. + * @todo Review all uses of this function to check if they can be made synchronous + */ + public addToNextPrompt( + phaseTarget: PhaseString, + mode: UiMode, + callback: () => void, + expireFn?: () => boolean, + awaitingActionInput = false, + ) { + this.prompts.push({ + phaseTarget, + mode, + callback, + expireFn, + awaitingActionInput, + }); + } + + /** + * Wrapper function to add green coloration to phase logs. + * @param args - Arguments to original logging function. + */ + private doLog(...args: unknown[]): void { + console.log(chalk.hex("#ffa500")(...args)); + } +} diff --git a/test/test-utils/interval-helper.ts b/test/test-utils/interval-helper.ts new file mode 100644 index 00000000000..0ba0c6e602a --- /dev/null +++ b/test/test-utils/interval-helper.ts @@ -0,0 +1,43 @@ +/** An array of pending timeouts and intervals to clear on test end. */ +const allTimeouts: NodeJS.Timeout[] = []; + +/** + * Set a temporary timeout that will be cleared upon test end. + * @param args - The arguments to the original function + * @returns The newly created timeout + */ +export function setTempTimeout( + ...args: Parameters> +): ReturnType>; +export function setTempTimeout(...args: Parameters): ReturnType; +export function setTempTimeout(...args: Parameters): ReturnType { + const timeout = global.setTimeout(...args); + allTimeouts.push(timeout); + return timeout; +} + +/** + * Set a temporary interval that will be cleared upon test end. + * @param args - The arguments to the original function + * @returns The newly created interval + */ +export function setTempInterval( + ...args: Parameters> +): ReturnType>; +export function setTempInterval(...args: Parameters): ReturnType; +export function setTempInterval(...args: Parameters): ReturnType { + const interval = global.setInterval(...args); + allTimeouts.push(interval); + return interval; +} + +/** Clear all lingering timeouts on test end. */ +export function clearAllTimeouts() { + // NB: The absolute WORST CASE SCENARIO for this is us clearing a timeout twice in a row + // (behavior which MDN web docs has certified to be a no-op) + for (const timeout of allTimeouts) { + // clearTimeout works on both intervals and timeouts + clearTimeout(timeout); + } + allTimeouts.splice(0); +} diff --git a/test/test-utils/mocks/mock-phase.ts b/test/test-utils/mocks/mock-phase.ts new file mode 100644 index 00000000000..3d4e4870cd5 --- /dev/null +++ b/test/test-utils/mocks/mock-phase.ts @@ -0,0 +1,11 @@ +import { Phase } from "#app/phase"; +/** + * A rudimentary mock of a phase. + * Ends upon starting by default. + */ +export abstract class mockPhase extends Phase { + public phaseName: any; + start() { + this.end(); + } +} diff --git a/test/test-utils/phase-interceptor.ts b/test/test-utils/phase-interceptor.ts index 50de7e9f047..e84fbecaa04 100644 --- a/test/test-utils/phase-interceptor.ts +++ b/test/test-utils/phase-interceptor.ts @@ -1,480 +1,212 @@ -import { Phase } from "#app/phase"; +import type { PhaseString } from "#app/@types/phase-types"; +import type { BattleScene } from "#app/battle-scene"; +import type { Phase } from "#app/phase"; +import type { Constructor } from "#app/utils/common"; import { UiMode } from "#enums/ui-mode"; -import { AttemptRunPhase } from "#phases/attempt-run-phase"; -import { BattleEndPhase } from "#phases/battle-end-phase"; -import { BerryPhase } from "#phases/berry-phase"; -import { CheckSwitchPhase } from "#phases/check-switch-phase"; -import { CommandPhase } from "#phases/command-phase"; -import { DamageAnimPhase } from "#phases/damage-anim-phase"; -import { EggLapsePhase } from "#phases/egg-lapse-phase"; -import { EncounterPhase } from "#phases/encounter-phase"; -import { EndEvolutionPhase } from "#phases/end-evolution-phase"; -import { EnemyCommandPhase } from "#phases/enemy-command-phase"; -import { EvolutionPhase } from "#phases/evolution-phase"; -import { ExpPhase } from "#phases/exp-phase"; -import { FaintPhase } from "#phases/faint-phase"; -import { FormChangePhase } from "#phases/form-change-phase"; -import { GameOverModifierRewardPhase } from "#phases/game-over-modifier-reward-phase"; -import { GameOverPhase } from "#phases/game-over-phase"; -import { LearnMovePhase } from "#phases/learn-move-phase"; -import { LevelCapPhase } from "#phases/level-cap-phase"; -import { LoginPhase } from "#phases/login-phase"; -import { MessagePhase } from "#phases/message-phase"; -import { ModifierRewardPhase } from "#phases/modifier-reward-phase"; -import { MoveEffectPhase } from "#phases/move-effect-phase"; -import { MoveEndPhase } from "#phases/move-end-phase"; -import { MovePhase } from "#phases/move-phase"; -import { - MysteryEncounterBattlePhase, - MysteryEncounterOptionSelectedPhase, - MysteryEncounterPhase, - MysteryEncounterRewardsPhase, - PostMysteryEncounterPhase, -} from "#phases/mystery-encounter-phases"; -import { NewBattlePhase } from "#phases/new-battle-phase"; -import { NewBiomeEncounterPhase } from "#phases/new-biome-encounter-phase"; -import { NextEncounterPhase } from "#phases/next-encounter-phase"; -import { PartyExpPhase } from "#phases/party-exp-phase"; -import { PartyHealPhase } from "#phases/party-heal-phase"; -import { PokemonTransformPhase } from "#phases/pokemon-transform-phase"; -import { PositionalTagPhase } from "#phases/positional-tag-phase"; -import { PostGameOverPhase } from "#phases/post-game-over-phase"; -import { PostSummonPhase } from "#phases/post-summon-phase"; -import { QuietFormChangePhase } from "#phases/quiet-form-change-phase"; -import { RevivalBlessingPhase } from "#phases/revival-blessing-phase"; -import { RibbonModifierRewardPhase } from "#phases/ribbon-modifier-reward-phase"; -import { SelectBiomePhase } from "#phases/select-biome-phase"; -import { SelectGenderPhase } from "#phases/select-gender-phase"; -import { SelectModifierPhase } from "#phases/select-modifier-phase"; -import { SelectStarterPhase } from "#phases/select-starter-phase"; -import { SelectTargetPhase } from "#phases/select-target-phase"; -import { ShinySparklePhase } from "#phases/shiny-sparkle-phase"; -import { ShowAbilityPhase } from "#phases/show-ability-phase"; -import { StatStageChangePhase } from "#phases/stat-stage-change-phase"; -import { SummonPhase } from "#phases/summon-phase"; -import { SwitchPhase } from "#phases/switch-phase"; -import { SwitchSummonPhase } from "#phases/switch-summon-phase"; -import { TitlePhase } from "#phases/title-phase"; -import { ToggleDoublePositionPhase } from "#phases/toggle-double-position-phase"; -import { TurnEndPhase } from "#phases/turn-end-phase"; -import { TurnInitPhase } from "#phases/turn-init-phase"; -import { TurnStartPhase } from "#phases/turn-start-phase"; -import { UnavailablePhase } from "#phases/unavailable-phase"; -import { UnlockPhase } from "#phases/unlock-phase"; -import { VictoryPhase } from "#phases/victory-phase"; -import { ErrorInterceptor } from "#test/test-utils/error-interceptor"; -import type { PhaseClass, PhaseString } from "#types/phase-types"; -import { UI } from "#ui/ui"; +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports +import type { GameManager } from "#test/test-utils/game-manager"; +import type { PromptHandler } from "#test/test-utils/helpers/prompt-handler"; +import { setTimeout } from "timers/promises"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports +import { format } from "util"; +import chalk from "chalk"; +import { vi } from "vitest"; -export interface PromptHandler { - phaseTarget?: string; - mode?: UiMode; - callback?: () => void; - expireFn?: () => void; - awaitingActionInput?: boolean; -} - -type PhaseInterceptorPhase = PhaseClass | PhaseString; +/** + * The interceptor's current state. + * Possible values are the following: + * - `running`: The interceptor is currently running a phase. + * - `interrupted`: The interceptor has been interrupted by a UI prompt and is waiting for the caller to end it. + * - `idling`: The interceptor is not currently running a phase and is ready to start a new one. + */ +type StateType = "running" | "interrupted" | "idling"; +/** + * The PhaseInterceptor is a wrapper around the `BattleScene`'s {@linkcode PhaseManager}. + * It allows tests to exert finer control over the phase system, providing logging, manual advancing, and other helpful utilities. + */ export class PhaseInterceptor { - public scene; - public phases = {}; - public log: string[]; - private onHold; - private interval; - private promptInterval; - private intervalRun; - private prompts: PromptHandler[]; - private phaseFrom; - private inProgress; - private originalSetMode; - private originalSetOverlayMode; - private originalSuperEnd; - + private scene: BattleScene; /** - * List of phases with their corresponding start methods. - * - * CAUTION: If a phase and its subclasses (if any) both appear in this list, - * make sure that this list contains said phase AFTER all of its subclasses. - * This way, the phase's `prototype.start` is properly preserved during - * `initPhases()` so that its subclasses can use `super.start()` properly. + * A log of phases having been executed. + * Entries are appended each time {@linkcode run} is called, and can be cleared with {@linkcode clearLogs}. */ - private PHASES = [ - [LoginPhase, this.startPhase], - [TitlePhase, this.startPhase], - [SelectGenderPhase, this.startPhase], - [NewBiomeEncounterPhase, this.startPhase], - [SelectStarterPhase, this.startPhase], - [PostSummonPhase, this.startPhase], - [SummonPhase, this.startPhase], - [ToggleDoublePositionPhase, this.startPhase], - [CheckSwitchPhase, this.startPhase], - [ShowAbilityPhase, this.startPhase], - [MessagePhase, this.startPhase], - [TurnInitPhase, this.startPhase], - [CommandPhase, this.startPhase], - [EnemyCommandPhase, this.startPhase], - [TurnStartPhase, this.startPhase], - [MovePhase, this.startPhase], - [MoveEffectPhase, this.startPhase], - [DamageAnimPhase, this.startPhase], - [FaintPhase, this.startPhase], - [BerryPhase, this.startPhase], - [TurnEndPhase, this.startPhase], - [BattleEndPhase, this.startPhase], - [EggLapsePhase, this.startPhase], - [SelectModifierPhase, this.startPhase], - [NextEncounterPhase, this.startPhase], - [NewBattlePhase, this.startPhase], - [VictoryPhase, this.startPhase], - [LearnMovePhase, this.startPhase], - [MoveEndPhase, this.startPhase], - [StatStageChangePhase, this.startPhase], - [ShinySparklePhase, this.startPhase], - [SelectTargetPhase, this.startPhase], - [UnavailablePhase, this.startPhase], - [QuietFormChangePhase, this.startPhase], - [SwitchPhase, this.startPhase], - [SwitchSummonPhase, this.startPhase], - [PartyHealPhase, this.startPhase], - [FormChangePhase, this.startPhase], - [EvolutionPhase, this.startPhase], - [EndEvolutionPhase, this.startPhase], - [LevelCapPhase, this.startPhase], - [AttemptRunPhase, this.startPhase], - [SelectBiomePhase, this.startPhase], - [PositionalTagPhase, this.startPhase], - [PokemonTransformPhase, this.startPhase], - [MysteryEncounterPhase, this.startPhase], - [MysteryEncounterOptionSelectedPhase, this.startPhase], - [MysteryEncounterBattlePhase, this.startPhase], - [MysteryEncounterRewardsPhase, this.startPhase], - [PostMysteryEncounterPhase, this.startPhase], - [RibbonModifierRewardPhase, this.startPhase], - [GameOverModifierRewardPhase, this.startPhase], - [ModifierRewardPhase, this.startPhase], - [PartyExpPhase, this.startPhase], - [ExpPhase, this.startPhase], - [EncounterPhase, this.startPhase], - [GameOverPhase, this.startPhase], - [UnlockPhase, this.startPhase], - [PostGameOverPhase, this.startPhase], - [RevivalBlessingPhase, this.startPhase], - ]; + public log: PhaseString[] = []; + /** + * The interceptor's current state. + * Possible values are the following: + * - `running`: The interceptor is currently running a phase. + * - `interrupted`: The interceptor has been interrupted by a UI prompt and is waiting for the caller to end it. + * - `idling`: The interceptor is not currently running a phase and is ready to start a new one. + */ + private state: StateType = "idling"; - private endBySetMode = [ - TitlePhase, - SelectGenderPhase, - CommandPhase, - SelectModifierPhase, - MysteryEncounterPhase, - PostMysteryEncounterPhase, - ]; + private target: PhaseString; /** * Constructor to initialize the scene and properties, and to start the phase handling. - * @param scene - The scene to be managed. + * @param scene - The scene to be managed */ - constructor(scene) { + constructor(scene: BattleScene) { this.scene = scene; - this.onHold = []; - this.prompts = []; - this.clearLogs(); - this.startPromptHandler(); - this.initPhases(); + // Mock the private startCurrentPhase method to toggle `isRunning` rather than actually starting anything + vi.spyOn(this.scene.phaseManager as any, "startCurrentPhase").mockImplementation(() => { + this.state = "idling"; + }); + } + + /** + * Method to transition to a target phase. + * @param target - The name of the {@linkcode Phase} to transition to + * @param runTarget - Whether or not to run the target phase before resolving; default `true` + * @returns A Promise that resolves once {@linkcode target} has been reached. + * @todo remove `Constructor` from type signature in favor of phase strings + * @see {@linkcode toUIMode} Method for transitioning to a specific {@linkcode UiMode} + * @remarks + * This will not resolve for *any* reason until the target phase has been reached. + * @example + * await game.phaseInterceptor.to("MoveEffectPhase", false); + */ + public async to(target: PhaseString | Constructor, runTarget = true): Promise { + this.target = typeof target === "string" ? target : (target.name as PhaseString); + + const pm = this.scene.phaseManager; + + // TODO: remove bangs once signature is updated + let currentPhase: Phase = pm.getCurrentPhase()!; + + // NB: This has to use an interval to wait for UI prompts to activate. + // TODO: Rework after UI rework + await vi.waitUntil( + async () => { + // If we were interrupted by a UI prompt, we assume that the calling code will queue inputs to + // end the current phase manually, so we just wait for the phase to end from the caller. + if (this.state === "interrupted") { + this.doLog("PhaseInterceptor.to: Waiting for phase to end after being interrupted!"); + await setTimeout(50); + return false; + } + + currentPhase = pm.getCurrentPhase()!; + // TODO: Remove proof-of-concept error throw after signature update + if (!currentPhase) { + throw new Error("currentPhase is null after being started!"); + } + + if (currentPhase.is(this.target)) { + return true; + } + + // Current phase is different; run and wait for it to finish. + await this.run(currentPhase); + return false; + }, + { interval: 0, timeout: 5_000 }, + ); + + // We hit the target; run as applicable and wrap up. + if (!runTarget) { + this.doLog(`PhaseInterceptor.to: Stopping before running ${this.target}`); + return; + } + + await this.run(currentPhase); + this.doLog( + `PhaseInterceptor.to: Stopping ${this.state === "interrupted" ? `after reaching UiMode.${UiMode[this.scene.ui.getMode()]} during` : "on completion of"} ${this.target}`, + ); + } + + /** + * Internal wrapper method to start a phase and wait until it finishes. + * @param currentPhase - The {@linkcode Phase} to run + * @returns A Promise that resolves when the phase has completed running. + */ + private async run(currentPhase: Phase): Promise { + try { + this.state = "running"; + this.logPhase(currentPhase.phaseName); + currentPhase.start(); + await vi.waitUntil( + () => this.state !== "running", + { interval: 50, timeout: 20_000 }, // TODO: Figure out an appropriate timeout for individual phases + ); + } catch (error) { + throw error instanceof Error + ? error + : new Error( + `Unknown error occurred while running phase ${currentPhase.phaseName}!\nError: ${format("%O", error)}`, + ); + } + } + + /** + * If this is at the target phase, unlock the interceptor and + * return control back to the caller once the calling phase has finished. + * @remarks + * This should not be called by anything other than {@linkcode PromptHandler}. + */ + public checkMode(): void { + const currentPhase = this.scene.phaseManager.getCurrentPhase()!; + if (!currentPhase.is(this.target) || this.state === "interrupted") { + // Wrong phase / already interrupted = do nothing + return; + } + + // Interrupt the phase and return control to the caller + this.state = "interrupted"; + } + + /** + * Skip the next upcoming phase. + * @throws Error if currently running a phase. + * @remarks + * This function should be used for skipping phases _not yet started_. + * To end ones already in the process of running, use {@linkcode GameManager.endPhase}. + * @example + * await game.phaseInterceptor.to("LoginPhase", false); + * game.phaseInterceptor.shiftPhase(); + */ + public shiftPhase(): void { + const phaseName = this.scene.phaseManager.getCurrentPhase()!.phaseName; + if (this.state !== "idling") { + throw new Error(`shiftPhase attempted to skip phase ${phaseName} mid-execution!`); + } + this.doLog(`Skipping current phase ${phaseName}`); + this.scene.phaseManager.shiftPhase(); + } + + /** + * Deprecated no-op function. + * + * This was previously used to reset timers created using `setInterval` on test end. + * However, since we now use standard async functions to run phases, + * this function has become a no-op. + * @deprecated This is no longer needed and will be removed in a future PR + */ + public restoreOg() {} + + /** + * Method to log the start of a phase. + * @param phaseName - The name of the phase to log. + */ + private logPhase(phaseName: PhaseString) { + this.doLog(`Start Phase ${phaseName}`); + this.log.push(phaseName); } /** * Clears phase logs */ - clearLogs() { + public clearLogs(): void { this.log = []; } - rejectAll(error) { - if (this.inProgress) { - clearInterval(this.promptInterval); - clearInterval(this.interval); - clearInterval(this.intervalRun); - this.inProgress.onError(error); - } - } - /** - * Method to set the starting phase. - * @param phaseFrom - The phase to start from. - * @returns The instance of the PhaseInterceptor. + * Wrapper function to add green coloration to phase logs. + * @param args - Arguments to original logging function. */ - runFrom(phaseFrom: PhaseInterceptorPhase): PhaseInterceptor { - this.phaseFrom = phaseFrom; - return this; - } - - /** - * Method to transition to a target phase. - * @param phaseTo - The phase to transition to. - * @param runTarget - Whether or not to run the target phase; default `true`. - * @returns A promise that resolves when the transition is complete. - */ - async to(phaseTo: PhaseInterceptorPhase, runTarget = true): Promise { - return new Promise(async (resolve, reject) => { - ErrorInterceptor.getInstance().add(this); - if (this.phaseFrom) { - await this.run(this.phaseFrom).catch(e => reject(e)); - this.phaseFrom = null; - } - const targetName = typeof phaseTo === "string" ? phaseTo : phaseTo.name; - this.intervalRun = setInterval(async () => { - const currentPhase = this.onHold?.length && this.onHold[0]; - if (currentPhase && currentPhase.name === targetName) { - clearInterval(this.intervalRun); - if (!runTarget) { - return resolve(); - } - await this.run(currentPhase).catch(e => { - clearInterval(this.intervalRun); - return reject(e); - }); - return resolve(); - } - if (currentPhase && currentPhase.name !== targetName) { - await this.run(currentPhase).catch(e => { - clearInterval(this.intervalRun); - return reject(e); - }); - } - }); - }); - } - - /** - * Method to run a phase with an optional skip function. - * @param phaseTarget - The phase to run. - * @param skipFn - Optional skip function. - * @returns A promise that resolves when the phase is run. - */ - run(phaseTarget: PhaseInterceptorPhase, skipFn?: (className: PhaseClass) => boolean): Promise { - const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name; - this.scene.moveAnimations = null; // Mandatory to avoid crash - return new Promise(async (resolve, reject) => { - ErrorInterceptor.getInstance().add(this); - const interval = setInterval(async () => { - const currentPhase = this.onHold.shift(); - if (currentPhase) { - if (currentPhase.name !== targetName) { - clearInterval(interval); - const skip = skipFn?.(currentPhase.name); - if (skip) { - this.onHold.unshift(currentPhase); - ErrorInterceptor.getInstance().remove(this); - return resolve(); - } - clearInterval(interval); - return reject(`Wrong phase: this is ${currentPhase.name} and not ${targetName}`); - } - clearInterval(interval); - this.inProgress = { - name: currentPhase.name, - callback: () => { - ErrorInterceptor.getInstance().remove(this); - resolve(); - }, - onError: error => reject(error), - }; - currentPhase.call(); - } - }); - }); - } - - whenAboutToRun(phaseTarget: PhaseInterceptorPhase, _skipFn?: (className: PhaseClass) => boolean): Promise { - const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name; - this.scene.moveAnimations = null; // Mandatory to avoid crash - return new Promise(async (resolve, _reject) => { - ErrorInterceptor.getInstance().add(this); - const interval = setInterval(async () => { - const currentPhase = this.onHold[0]; - if (currentPhase?.name === targetName) { - clearInterval(interval); - resolve(); - } - }); - }); - } - - pop() { - this.onHold.pop(); - this.scene.phaseManager.shiftPhase(); - } - - /** - * Remove the current phase from the phase interceptor. - * - * Do not call this unless absolutely necessary. This function is intended - * for cleaning up the phase interceptor when, for whatever reason, a phase - * is manually ended without using the phase interceptor. - * - * @param shouldRun Whether or not the current scene should also be run. - */ - shift(shouldRun = false): void { - this.onHold.shift(); - if (shouldRun) { - this.scene.phaseManager.shiftPhase(); - } - } - - /** - * Method to initialize phases and their corresponding methods. - */ - initPhases() { - this.originalSetMode = UI.prototype.setMode; - this.originalSetOverlayMode = UI.prototype.setOverlayMode; - this.originalSuperEnd = Phase.prototype.end; - UI.prototype.setMode = (mode, ...args) => this.setMode.call(this, mode, ...args); - Phase.prototype.end = () => this.superEndPhase.call(this); - for (const [phase, methodStart] of this.PHASES) { - const originalStart = phase.prototype.start; - this.phases[phase.name] = { - start: originalStart, - endBySetMode: this.endBySetMode.some(elm => elm.name === phase.name), - }; - phase.prototype.start = () => methodStart.call(this, phase); - } - } - - /** - * Method to start a phase and log it. - * @param phase - The phase to start. - */ - startPhase(phase: PhaseClass) { - this.log.push(phase.name); - const instance = this.scene.phaseManager.getCurrentPhase(); - this.onHold.push({ - name: phase.name, - call: () => { - this.phases[phase.name].start.apply(instance); - }, - }); - } - - unlock() { - this.inProgress?.callback(); - this.inProgress = undefined; - } - - /** - * Method to end a phase and log it. - * @param phase - The phase to start. - */ - superEndPhase() { - const instance = this.scene.phaseManager.getCurrentPhase(); - this.originalSuperEnd.apply(instance); - this.inProgress?.callback(); - this.inProgress = undefined; - } - - /** - * m2m to set mode. - * @param mode - The {@linkcode UiMode} to set. - * @param args - Additional arguments to pass to the original method. - */ - setMode(mode: UiMode, ...args: unknown[]): Promise { - const currentPhase = this.scene.phaseManager.getCurrentPhase(); - const instance = this.scene.ui; - console.log("setMode", `${UiMode[mode]} (=${mode})`, args); - const ret = this.originalSetMode.apply(instance, [mode, ...args]); - if (!this.phases[currentPhase.constructor.name]) { - throw new Error( - `missing ${currentPhase.constructor.name} in phaseInterceptor PHASES list --- Add it to PHASES inside of /test/utils/phaseInterceptor.ts`, - ); - } - if (this.phases[currentPhase.constructor.name].endBySetMode) { - this.inProgress?.callback(); - this.inProgress = undefined; - } - return ret; - } - - /** - * mock to set overlay mode - * @param mode - The {@linkcode Mode} to set. - * @param args - Additional arguments to pass to the original method. - */ - setOverlayMode(mode: UiMode, ...args: unknown[]): Promise { - const instance = this.scene.ui; - console.log("setOverlayMode", `${UiMode[mode]} (=${mode})`, args); - const ret = this.originalSetOverlayMode.apply(instance, [mode, ...args]); - return ret; - } - - /** - * Method to start the prompt handler. - */ - startPromptHandler() { - this.promptInterval = setInterval(() => { - if (this.prompts.length) { - const actionForNextPrompt = this.prompts[0]; - const expireFn = actionForNextPrompt.expireFn?.(); - const currentMode = this.scene.ui.getMode(); - const currentPhase = this.scene.phaseManager.getCurrentPhase()?.constructor.name; - const currentHandler = this.scene.ui.getHandler(); - if (expireFn) { - this.prompts.shift(); - } else if ( - currentMode === actionForNextPrompt.mode && - currentPhase === actionForNextPrompt.phaseTarget && - currentHandler.active && - (!actionForNextPrompt.awaitingActionInput || - (actionForNextPrompt.awaitingActionInput && currentHandler.awaitingActionInput)) - ) { - const prompt = this.prompts.shift(); - if (prompt?.callback) { - prompt.callback(); - } - } - } - }); - } - - /** - * Method to add an action to the next prompt. - * @param phaseTarget - The target phase for the prompt. - * @param mode - The mode of the UI. - * @param callback - The callback function to execute. - * @param expireFn - The function to determine if the prompt has expired. - * @param awaitingActionInput - ???; default `false` - */ - addToNextPrompt( - phaseTarget: string, - mode: UiMode, - callback: () => void, - expireFn?: () => void, - awaitingActionInput = false, - ) { - this.prompts.push({ - phaseTarget, - mode, - callback, - expireFn, - awaitingActionInput, - }); - } - - /** - * Restores the original state of phases and clears intervals. - * - * This function iterates through all phases and resets their `start` method to the original - * function stored in `this.phases`. Additionally, it clears the `promptInterval` and `interval`. - */ - restoreOg() { - for (const [phase] of this.PHASES) { - phase.prototype.start = this.phases[phase.name].start; - } - UI.prototype.setMode = this.originalSetMode; - UI.prototype.setOverlayMode = this.originalSetOverlayMode; - Phase.prototype.end = this.originalSuperEnd; - clearInterval(this.promptInterval); - clearInterval(this.interval); - clearInterval(this.intervalRun); + private doLog(...args: unknown[]): void { + // Use chalk highlighting instead of normal green due to Node.js not respecting `%c` CSS color setting + console.log(chalk.green(...args)); } } diff --git a/test/test-utils/tests/helpers/prompt-handler.test.ts b/test/test-utils/tests/helpers/prompt-handler.test.ts new file mode 100644 index 00000000000..4caa1feb2a8 --- /dev/null +++ b/test/test-utils/tests/helpers/prompt-handler.test.ts @@ -0,0 +1,153 @@ +import type { PhaseString } from "#app/@types/phase-types"; +import type { Phase } from "#app/phase"; +import type { AwaitableUiHandler } from "#app/ui/awaitable-ui-handler"; +import { UiMode } from "#enums/ui-mode"; +import type { GameManager } from "#test/test-utils/game-manager"; +import { PromptHandler } from "#test/test-utils/helpers/prompt-handler"; +import type { PhaseInterceptor } from "#test/test-utils/phase-interceptor"; +import type { UI } from "#ui/ui"; +import { beforeAll, beforeEach, describe, expect, it, type Mock, vi } from "vitest"; + +describe("Utils - PromptHandler", () => { + let promptHandler: PromptHandler; + let handler: AwaitableUiHandler; + + let callback1: Mock; + let callback2: Mock; + let setModeCallback: Mock; + let checkModeCallback: Mock; + + beforeAll(() => { + setModeCallback = vi.fn(); + checkModeCallback = vi.fn(); + callback1 = vi.fn(() => console.log("callback 1 called!")).mockName("callback 1"); + callback2 = vi.fn(() => console.log("callback 2 called!")).mockName("callback 2"); + }); + + beforeEach(() => { + handler = { + active: true, + show: () => {}, + awaitingActionInput: true, + } as unknown as AwaitableUiHandler; + + promptHandler = new PromptHandler({ + scene: { + ui: { + getHandler: () => handler, + setModeInternal: () => { + setModeCallback(); + return Promise.resolve(); + }, + getMode: () => UiMode.TEST_DIALOGUE, + } as unknown as UI, + phaseManager: { + getCurrentPhase: () => + ({ + phaseName: "testDialoguePhase", + }) as unknown as Phase, + }, + }, + phaseInterceptor: { + checkMode: () => { + checkModeCallback(); + }, + } as PhaseInterceptor, + } as GameManager); + }); + + function onNextPrompt( + target: string, + mode: UiMode, + callback: () => void, + expireFn?: () => boolean, + awaitingActionInput = false, + ) { + promptHandler.addToNextPrompt(target as unknown as PhaseString, mode, callback, expireFn, awaitingActionInput); + } + + describe("setMode", () => { + it("should wrap and pass along original function arguments", async () => { + const setModeSpy = vi.spyOn(promptHandler as any, "setMode"); + promptHandler["game"].scene.ui["setModeInternal"](UiMode.PARTY, false, false, false, []); + + expect(setModeSpy).toHaveBeenCalledExactlyOnceWith([UiMode.PARTY, false, false, false, []]); + expect(setModeCallback).toHaveBeenCalledAfter(setModeSpy); + }); + + it("should call PhaseInterceptor.checkMode", async () => { + promptHandler["game"].scene.ui["setModeInternal"](UiMode.PARTY, false, false, false, []); + + expect(checkModeCallback).toHaveBeenCalledOnce(); + }); + }); + + describe("doPromptCheck", () => { + it("should check and remove the first prompt", async () => { + onNextPrompt("testDialoguePhase", UiMode.TEST_DIALOGUE, () => callback1()); + onNextPrompt("testDialoguePhase", UiMode.TEST_DIALOGUE, () => callback2()); + promptHandler["doPromptCheck"](); + + expect(callback1).toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(promptHandler["prompts"]).toHaveLength(1); + }); + + it.each<{ reason: string; callback: () => void }>([ + { + reason: "wrong UI mode", + callback: () => onNextPrompt("testDialoguePhase", UiMode.ACHIEVEMENTS, () => callback1()), + }, + { + reason: "wrong phase", + callback: () => onNextPrompt("wrong phase", UiMode.TEST_DIALOGUE, () => callback1()), + }, + { + reason: "UI handler is inactive", + callback: () => { + handler.active = false; + onNextPrompt("testDialoguePhase", UiMode.TEST_DIALOGUE, () => callback1()); + }, + }, + { + reason: "UI handler is not awaiting input", + callback: () => { + handler["awaitingActionInput"] = false; + onNextPrompt("testDialoguePhase", UiMode.TEST_DIALOGUE, () => callback1(), undefined, true); + }, + }, + ])("should skip callback and keep in queue if $reason", async ({ callback }) => { + callback(); + onNextPrompt("testDialoguePhase", UiMode.TEST_DIALOGUE, () => callback2); + promptHandler["doPromptCheck"](); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(promptHandler["prompts"]).toHaveLength(2); + }); + + it("should remove expired prompts without blocking", async () => { + onNextPrompt( + "testDialoguePhase", + UiMode.TEST_DIALOGUE, + () => callback1(), + () => true, + ); + onNextPrompt( + "testDialoguePhase", + UiMode.TEST_DIALOGUE, + () => callback2(), + () => false, + ); + promptHandler["doPromptCheck"](); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(promptHandler["prompts"]).toHaveLength(1); + + promptHandler["doPromptCheck"](); + expect(callback2).toHaveBeenCalledOnce(); + expect(promptHandler["prompts"]).toHaveLength(0); + }); + }); +}); diff --git a/test/test-utils/tests/phase-interceptor/integration.test.ts b/test/test-utils/tests/phase-interceptor/integration.test.ts new file mode 100644 index 00000000000..90b7f2d9e22 --- /dev/null +++ b/test/test-utils/tests/phase-interceptor/integration.test.ts @@ -0,0 +1,62 @@ +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { UiMode } from "#enums/ui-mode"; +import { GameManager } from "#test/test-utils/game-manager"; +import Phaser from "phaser"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Utils - Phase Interceptor - Integration", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + }); + + it("runToTitle", async () => { + await game.runToTitle(); + + expect(game.scene.ui.getMode()).toBe(UiMode.TITLE); + expect(game.scene.phaseManager.getCurrentPhase()?.phaseName).toBe("TitlePhase"); + }); + + it("runToSummon", async () => { + await game.classicMode.runToSummon([SpeciesId.ABOMASNOW]); + + expect(game.scene.phaseManager.getCurrentPhase()?.phaseName).toBe("SummonPhase"); + }); + + it("startBattle", async () => { + await game.classicMode.startBattle([SpeciesId.RABOOT]); + + expect(game.scene.ui.getMode()).toBe(UiMode.COMMAND); + expect(game.scene.phaseManager.getCurrentPhase()?.phaseName).toBe("CommandPhase"); + }); + + it("1 Full Turn", async () => { + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); + + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.toNextTurn(); + + expect(game.scene.ui.getMode()).toBe(UiMode.COMMAND); + expect(game.scene.phaseManager.getCurrentPhase()?.phaseName).toBe("CommandPhase"); + }); + + it("should not break when phase ended early via prompt", async () => { + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); + game.onNextPrompt("CommandPhase", UiMode.FIGHT, () => { + game.endPhase(); + }); + + game.move.use(MoveId.BOUNCE); + await game.phaseInterceptor.to("EnemyCommandPhase"); + }); +}); diff --git a/test/test-utils/tests/phase-interceptor/unit.test.ts b/test/test-utils/tests/phase-interceptor/unit.test.ts new file mode 100644 index 00000000000..789309baa70 --- /dev/null +++ b/test/test-utils/tests/phase-interceptor/unit.test.ts @@ -0,0 +1,150 @@ +import type { PhaseString } from "#app/@types/phase-types"; +import { globalScene } from "#app/global-scene"; +import type { Phase } from "#app/phase"; +import type { Constructor } from "#app/utils/common"; +import { GameManager } from "#test/test-utils/game-manager"; +import { mockPhase } from "#test/test-utils/mocks/mock-phase"; +import Phaser from "phaser"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// TODO: Move these to `mock-phase.ts` if/when unit tests for the phase manager are created +class applePhase extends mockPhase { + public readonly phaseName = "applePhase"; +} + +class bananaPhase extends mockPhase { + public readonly phaseName = "bananaPhase"; +} + +class coconutPhase extends mockPhase { + public readonly phaseName = "coconutPhase"; +} + +class oneSecTimerPhase extends mockPhase { + public readonly phaseName = "oneSecTimerPhase"; + start() { + setTimeout(() => { + console.log("1 sec passed!"); + this.end(); + }, 1000); + } +} + +class unshifterPhase extends mockPhase { + public readonly phaseName = "unshifterPhase"; + start() { + globalScene.phaseManager.unshiftPhase(new applePhase() as unknown as Phase); + globalScene.phaseManager.unshiftPhase(new bananaPhase() as unknown as Phase); + globalScene.phaseManager.unshiftPhase(new coconutPhase() as unknown as Phase); + this.end(); + } +} + +describe("Utils - Phase Interceptor - Unit", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + setPhases(applePhase, bananaPhase, coconutPhase, bananaPhase, coconutPhase); + }); + + /** + * Helper function to set the phase manager's phases to the specified values and start the first one. + * @param phases - An array of constructors to {@linkcode Phase}s to set. + * Constructors must have no arguments. + */ + function setPhases(phase: Constructor, ...phases: Constructor[]) { + game.scene.phaseManager.clearAllPhases(); + game.scene.phaseManager.phaseQueue = [phase, ...phases].map(m => new m()) as Phase[]; + game.scene.phaseManager.shiftPhase(); // start the thing going + } + + function getQueuedPhases(): string[] { + return game.scene.phaseManager["phaseQueuePrepend"] + .concat(game.scene.phaseManager.phaseQueue) + .map(p => p.phaseName); + } + + function getCurrentPhaseName(): string { + return game.scene.phaseManager.getCurrentPhase()?.phaseName ?? "no phase"; + } + + /** Wrapper function to make TS not complain about incompatible argument typing on `PhaseString`. */ + function to(phaseName: string, runTarget = true) { + return game.phaseInterceptor.to(phaseName as unknown as PhaseString, runTarget); + } + + describe("to", () => { + it("should start the specified phase and resolve after it ends", async () => { + await to("applePhase"); + + expect(getCurrentPhaseName()).toBe("bananaPhase"); + expect(getQueuedPhases()).toEqual(["coconutPhase", "bananaPhase", "coconutPhase"]); + expect(game.phaseInterceptor.log).toEqual(["applePhase"]); + }); + + it("should run to the specified phase without starting/logging", async () => { + await to("applePhase", false); + + expect(getCurrentPhaseName()).toBe("applePhase"); + expect(getQueuedPhases()).toEqual(["bananaPhase", "coconutPhase", "bananaPhase", "coconutPhase"]); + expect(game.phaseInterceptor.log).toEqual([]); + + await to("applePhase", false); + + // should not do anything + expect(getCurrentPhaseName()).toBe("applePhase"); + expect(getQueuedPhases()).toEqual(["bananaPhase", "coconutPhase", "bananaPhase", "coconutPhase"]); + expect(game.phaseInterceptor.log).toEqual([]); + }); + + it("should run all phases between start and the first instance of target", async () => { + await to("coconutPhase"); + + expect(getCurrentPhaseName()).toBe("bananaPhase"); + expect(getQueuedPhases()).toEqual(["coconutPhase"]); + expect(game.phaseInterceptor.log).toEqual(["applePhase", "bananaPhase", "coconutPhase"]); + }); + + it("should work on newly unshifted phases", async () => { + setPhases(unshifterPhase, coconutPhase); // adds applePhase, bananaPhase and coconutPhase to queue + await to("bananaPhase"); + + expect(getCurrentPhaseName()).toBe("coconutPhase"); + expect(getQueuedPhases()).toEqual(["coconutPhase"]); + expect(game.phaseInterceptor.log).toEqual(["unshifterPhase", "applePhase", "bananaPhase"]); + }); + + it("should wait for asynchronous phases to end", async () => { + setPhases(oneSecTimerPhase, coconutPhase); + const callback = vi.fn(() => console.log("fffffff")); + const spy = vi.spyOn(oneSecTimerPhase.prototype, "end"); + setTimeout(() => { + callback(); + }, 500); + await to("coconutPhase"); + expect(callback).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); + }); + }); + + describe("shift", () => { + it("should skip the next phase in line without starting it", async () => { + const startSpy = vi.spyOn(applePhase.prototype, "start"); + + game.phaseInterceptor.shiftPhase(); + + expect(getCurrentPhaseName()).toBe("bananaPhase"); + expect(getQueuedPhases()).toEqual(["coconutPhase", "bananaPhase", "coconutPhase"]); + expect(startSpy).not.toHaveBeenCalled(); + expect(game.phaseInterceptor.log).toEqual([]); + }); + }); +}); diff --git a/test/test-utils/tests/timeout-reset.test.ts b/test/test-utils/tests/timeout-reset.test.ts new file mode 100644 index 00000000000..343c4e2fc36 --- /dev/null +++ b/test/test-utils/tests/timeout-reset.test.ts @@ -0,0 +1,25 @@ +import { setTempInterval } from "#test/test-utils/interval-helper"; +import { setTimeout } from "timers/promises"; +import { describe, expect, it, vi } from "vitest"; + +describe("Timeout resets", () => { + let counter = 1; + + it.sequential("should not reset intervals during test", async () => { + vi.spyOn(global, "clearInterval"); + setTempInterval(() => { + console.log("interval called"); + counter++; + expect(counter).toBeLessThan(200); + }, 50); + await vi.waitUntil(() => counter > 2); + expect(clearInterval).not.toHaveBeenCalled(); + }); + + it.sequential("should reset intervals after test end", async () => { + const initCounter = counter; + await setTimeout(500); + // Were the interval active, the counter would have increased by now + expect(counter).toBe(initCounter); + }); +}); diff --git a/test/vitest.setup.ts b/test/vitest.setup.ts index be35e18e2e9..631c15499e1 100644 --- a/test/vitest.setup.ts +++ b/test/vitest.setup.ts @@ -1,6 +1,7 @@ import "vitest-canvas-mock"; +import { clearAllTimeouts } from "#test/test-utils/interval-helper"; import { initTests } from "#test/test-utils/test-file-initialization"; -import { afterAll, beforeAll, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, vi } from "vitest"; /** Set the timezone to UTC for tests. */ @@ -48,8 +49,6 @@ vi.mock("i18next", async importOriginal => { return await importOriginal(); }); -global.testFailed = false; - beforeAll(() => { initTests(); }); @@ -58,3 +57,17 @@ afterAll(() => { global.server.close(); console.log("Closing i18n MSW server!"); }); + +afterEach(() => { + clearAllTimeouts(); +}); + +process.on("uncaughtException", err => { + clearAllTimeouts(); + throw err; +}); + +process.on("unhandledRejection", err => { + clearAllTimeouts(); + throw err; +});