From b2d796eca35776dffb03d31bd47b5184da2fe233 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 20 Aug 2025 23:35:45 -0400 Subject: [PATCH] Squashed changes... again --- scripts/create-test/boilerplates/default.ts | 6 +- src/battle-scene.ts | 10 +- src/phase-manager.ts | 25 +- src/phases/title-phase.ts | 28 +- test/moves/parting-shot.test.ts | 27 +- .../bug-type-superfan-encounter.test.ts | 2 +- test/test-utils/error-interceptor.ts | 49 -- test/test-utils/game-manager.ts | 44 +- test/test-utils/game-wrapper.ts | 12 +- test/test-utils/helpers/prompt-handler.ts | 159 +++++ test/test-utils/helpers/reload-helper.ts | 9 +- test/test-utils/mocks/mock-phase.ts | 11 + test/test-utils/phase-interceptor.ts | 603 ++++++------------ .../tests/helpers/prompt-handler.test.ts | 153 +++++ .../phase-interceptor/integration.test.ts | 62 ++ .../tests/phase-interceptor/unit.test.ts | 150 +++++ test/vitest.setup.ts | 22 +- 17 files changed, 831 insertions(+), 541 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/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 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 4bd41299d8f..4e3996f687c 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -392,7 +392,11 @@ export class BattleScene extends SceneBase { }); } - 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(); @@ -417,6 +421,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"); @@ -597,6 +602,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); } @@ -1138,6 +1145,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..e971b732e37 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -389,12 +389,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 +416,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/src/phases/title-phase.ts b/src/phases/title-phase.ts index 15d92ba2812..11a2ccc482c 100644 --- a/src/phases/title-phase.ts +++ b/src/phases/title-phase.ts @@ -168,24 +168,22 @@ export class TitlePhase extends Phase { globalScene.ui.setMode(UiMode.TITLE, config); } - loadSaveSlot(slotId: number): void { + async loadSaveSlot(slotId: number): Promise { globalScene.sessionSlotId = slotId > -1 || !loggedInUser ? slotId : loggedInUser.lastSessionSlot; globalScene.ui.setMode(UiMode.MESSAGE); globalScene.ui.resetModeChain(); - globalScene.gameData - .loadSession(slotId, slotId === -1 ? this.lastSessionData : undefined) - .then((success: boolean) => { - if (success) { - this.loaded = true; - globalScene.ui.showText(i18next.t("menu:sessionSuccess"), null, () => this.end()); - } else { - this.end(); - } - }) - .catch(err => { - console.error(err); - globalScene.ui.showText(i18next.t("menu:failedToLoadSession"), null); - }); + try { + const success = await globalScene.gameData.loadSession(slotId, slotId === -1 ? this.lastSessionData : undefined); + if (success) { + this.loaded = true; + globalScene.ui.showText(i18next.t("menu:sessionSuccess"), null, () => this.end()); + } else { + this.end(); + } + } catch (err) { + console.error(err); + globalScene.ui.showText(i18next.t("menu:failedToLoadSession"), null); + } } initDailyRun(): void { diff --git a/test/moves/parting-shot.test.ts b/test/moves/parting-shot.test.ts index e9400aef29b..eafc80bcf0b 100644 --- a/test/moves/parting-shot.test.ts +++ b/test/moves/parting-shot.test.ts @@ -73,31 +73,9 @@ describe("Moves - Parting Shot", () => { SpeciesId.ABRA, ]); - // use Memento 3 times to debuff enemy - game.move.select(MoveId.MEMENTO); - await game.phaseInterceptor.to("FaintPhase"); - expect(game.field.getPlayerPokemon().isFainted()).toBe(true); - game.doSelectPartyPokemon(1); - - await game.phaseInterceptor.to("TurnInitPhase", false); - game.move.select(MoveId.MEMENTO); - await game.phaseInterceptor.to("FaintPhase"); - expect(game.field.getPlayerPokemon().isFainted()).toBe(true); - game.doSelectPartyPokemon(2); - - await game.phaseInterceptor.to("TurnInitPhase", false); - game.move.select(MoveId.MEMENTO); - await game.phaseInterceptor.to("FaintPhase"); - expect(game.field.getPlayerPokemon().isFainted()).toBe(true); - game.doSelectPartyPokemon(3); - - // set up done - await game.phaseInterceptor.to("TurnInitPhase", false); const enemyPokemon = game.field.getEnemyPokemon(); - expect(enemyPokemon).toBeDefined(); - - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-6); - expect(enemyPokemon.getStatStage(Stat.SPATK)).toBe(-6); + enemyPokemon.setStatStage(Stat.ATK, -6); + enemyPokemon.setStatStage(Stat.SPATK, -6); // now parting shot should fail game.move.select(MoveId.PARTING_SHOT); @@ -136,7 +114,6 @@ describe("Moves - Parting Shot", () => { await game.classicMode.startBattle([SpeciesId.SNORLAX, SpeciesId.MEOWTH]); const enemyPokemon = game.field.getEnemyPokemon(); - expect(enemyPokemon).toBeDefined(); game.move.select(MoveId.PARTING_SHOT); diff --git a/test/mystery-encounter/encounters/bug-type-superfan-encounter.test.ts b/test/mystery-encounter/encounters/bug-type-superfan-encounter.test.ts index 13d3c030c63..aacc76e8a30 100644 --- a/test/mystery-encounter/encounters/bug-type-superfan-encounter.test.ts +++ b/test/mystery-encounter/encounters/bug-type-superfan-encounter.test.ts @@ -366,7 +366,7 @@ describe("Bug-Type Superfan - Mystery Encounter", () => { await skipBattleRunMysteryEncounterRewardsPhase(game, false); expect(scene.phaseManager.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterRewardsPhase.name); - game.phaseInterceptor["prompts"] = []; // Clear out prompt handlers + game.promptHandler["prompts"] = []; // Clear out prompt handlers game.onNextPrompt("MysteryEncounterRewardsPhase", UiMode.OPTION_SELECT, () => { game.endPhase(); }); 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.ts b/test/test-utils/game-manager.ts index 57badd866b1..a4045c6c2c5 100644 --- a/test/test-utils/game-manager.ts +++ b/test/test-utils/game-manager.ts @@ -28,7 +28,6 @@ import type { SelectTargetPhase } from "#phases/select-target-phase"; import { TurnEndPhase } from "#phases/turn-end-phase"; import { TurnInitPhase } from "#phases/turn-init-phase"; import { TurnStartPhase } from "#phases/turn-start-phase"; -import { ErrorInterceptor } from "#test/test-utils/error-interceptor"; import { generateStarter } from "#test/test-utils/game-manager-utils"; import { GameWrapper } from "#test/test-utils/game-wrapper"; import { ChallengeModeHelper } from "#test/test-utils/helpers/challenge-mode-helper"; @@ -38,12 +37,14 @@ import { FieldHelper } from "#test/test-utils/helpers/field-helper"; import { ModifierHelper } from "#test/test-utils/helpers/modifiers-helper"; import { MoveHelper } from "#test/test-utils/helpers/move-helper"; import { OverridesHelper } from "#test/test-utils/helpers/overrides-helper"; +import { PromptHandler } from "#test/test-utils/helpers/prompt-handler"; import { ReloadHelper } from "#test/test-utils/helpers/reload-helper"; import { SettingsHelper } from "#test/test-utils/helpers/settings-helper"; import type { InputsHandler } from "#test/test-utils/inputs-handler"; import { MockFetch } from "#test/test-utils/mocks/mock-fetch"; import { PhaseInterceptor } from "#test/test-utils/phase-interceptor"; import { TextInterceptor } from "#test/test-utils/text-interceptor"; +import type { PhaseClass, PhaseString } from "#types/phase-types"; import type { BallUiHandler } from "#ui/ball-ui-handler"; import type { BattleMessageUiHandler } from "#ui/battle-message-ui-handler"; import type { CommandUiHandler } from "#ui/command-ui-handler"; @@ -65,6 +66,7 @@ export class GameManager { public phaseInterceptor: PhaseInterceptor; public textInterceptor: TextInterceptor; public inputsHandler: InputsHandler; + public readonly promptHandler: PromptHandler; public readonly override: OverridesHelper; public readonly move: MoveHelper; public readonly classicMode: ClassicModeHelper; @@ -82,7 +84,6 @@ export class GameManager { */ constructor(phaserGame: Phaser.Game, bypassLogin = true) { localStorage.clear(); - ErrorInterceptor.getInstance().clear(); // Simulate max rolls on RNG functions // TODO: Create helpers for disabling/enabling battle RNG BattleScene.prototype.randBattleSeedInt = (range, min = 0) => min + range - 1; @@ -102,6 +103,7 @@ export class GameManager { } this.textInterceptor = new TextInterceptor(this.scene); + this.promptHandler = new PromptHandler(this); this.override = new OverridesHelper(this); this.move = new MoveHelper(this); this.classicMode = new ClassicModeHelper(this); @@ -157,7 +159,8 @@ export class GameManager { } /** - * End the currently running phase immediately. + * End the current phase immediately. + * @see {@linkcode PhaseInterceptor.shiftPhase} Function to skip the next upcoming phase */ endPhase() { this.scene.phaseManager.getCurrentPhase()?.end(); @@ -170,15 +173,18 @@ export class GameManager { * @param mode - The mode to wait for. * @param callback - The callback function to execute on next prompt. * @param expireFn - Optional function to determine if the prompt has expired. + * @param awaitingActionInput - If true, will prevent the prompt from activating until the current {@linkcode AwaitableUiHandler} + * is awaiting input; default `false` + * @todo Remove in favor of {@linkcode promptHandler.addToNextPrompt} */ onNextPrompt( - phaseTarget: string, + phaseTarget: PhaseString, mode: UiMode, callback: () => void, - expireFn?: () => void, + expireFn?: () => boolean, awaitingActionInput = false, ) { - this.phaseInterceptor.addToNextPrompt(phaseTarget, mode, callback, expireFn, awaitingActionInput); + this.promptHandler.addToNextPrompt(phaseTarget, mode, callback, expireFn, awaitingActionInput); } /** @@ -188,7 +194,7 @@ export class GameManager { async runToTitle(): Promise { // Go to login phase and skip past it await this.phaseInterceptor.to("LoginPhase", false); - this.phaseInterceptor.shiftPhase(true); + this.phaseInterceptor.shiftPhase(); await this.phaseInterceptor.to("TitlePhase"); // TODO: This should be moved to a separate initialization method @@ -365,14 +371,14 @@ export class GameManager { * Transition to the first {@linkcode CommandPhase} of the next turn. * @returns A promise that resolves once the next {@linkcode CommandPhase} has been reached. */ - async toNextTurn() { + async toNextTurn(): Promise { await this.phaseInterceptor.to("TurnInitPhase"); await this.phaseInterceptor.to("CommandPhase"); console.log("==================[New Turn]=================="); } /** Transition to the {@linkcode TurnEndPhase | end of the current turn}. */ - async toEndOfTurn() { + async toEndOfTurn(): Promise { await this.phaseInterceptor.to("TurnEndPhase"); console.log("==================[End of Turn]=================="); } @@ -381,7 +387,7 @@ export class GameManager { * Queue up button presses to skip taking an item on the next {@linkcode SelectModifierPhase}, * and then transition to the next {@linkcode CommandPhase}. */ - async toNextWave() { + async toNextWave(): Promise { this.doSelectModifier(); // forcibly end the message box for switching pokemon @@ -404,7 +410,7 @@ export class GameManager { * Check if the player has won the battle. * @returns whether the player has won the battle (all opposing Pokemon have been fainted) */ - isVictory() { + isVictory(): boolean { return this.scene.currentBattle.enemyParty.every(pokemon => pokemon.isFainted()); } @@ -413,9 +419,17 @@ export class GameManager { * @param phaseTarget - The target phase. * @returns Whether the current phase matches the target phase */ - isCurrentPhase(phaseTarget) { - const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name; - return this.scene.phaseManager.getCurrentPhase()?.constructor.name === targetName; + isCurrentPhase(phaseTarget: PhaseString): boolean; + /** + * Checks if the current phase matches the target phase. + * @param phaseTarget - The target phase. + * @returns Whether the current phase matches the target phase + * @deprecated - Use phaseClass + */ + isCurrentPhase(phaseTarget: PhaseClass): boolean; + isCurrentPhase(phaseTarget: PhaseString | PhaseClass): boolean { + const targetName = typeof phaseTarget === "string" ? phaseTarget : (phaseTarget.name as PhaseString); + return this.scene.phaseManager.getCurrentPhase()?.is(targetName) ?? false; } /** @@ -503,7 +517,7 @@ export class GameManager { * @param inPhase - Which phase to expect the selection to occur in. Defaults to `SwitchPhase` * (which is where the majority of non-command switch operations occur). */ - doSelectPartyPokemon(slot: number, inPhase = "SwitchPhase") { + doSelectPartyPokemon(slot: number, inPhase: PhaseString = "SwitchPhase") { this.onNextPrompt(inPhase, UiMode.PARTY, () => { const partyHandler = this.scene.ui.getHandler() as PartyUiHandler; diff --git a/test/test-utils/game-wrapper.ts b/test/test-utils/game-wrapper.ts index 1a906bf8492..8069da027ef 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..989c09ff4be --- /dev/null +++ b/test/test-utils/helpers/prompt-handler.ts @@ -0,0 +1,159 @@ +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 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 + * and does not account for UI handlers not accepting input + */ +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"]; + + public static runInterval?: NodeJS.Timeout; + + 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. + if (PromptHandler.runInterval) { + throw new Error("Prompt handler run interval was not properly cleared on test end!"); + } + PromptHandler.runInterval = setInterval(() => 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]; + + // 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/helpers/reload-helper.ts b/test/test-utils/helpers/reload-helper.ts index 7166f1b6cf9..7afc119d41d 100644 --- a/test/test-utils/helpers/reload-helper.ts +++ b/test/test-utils/helpers/reload-helper.ts @@ -38,11 +38,7 @@ export class ReloadHelper extends GameManagerHelper { scene.phaseManager.clearPhaseQueue(); // Set the last saved session to the desired session data - vi.spyOn(scene.gameData, "getSession").mockReturnValue( - new Promise((resolve, _reject) => { - resolve(this.sessionData); - }), - ); + vi.spyOn(scene.gameData, "getSession").mockReturnValue(Promise.resolve(this.sessionData)); scene.phaseManager.unshiftPhase(titlePhase); this.game.endPhase(); // End the currently ongoing battle @@ -56,8 +52,7 @@ export class ReloadHelper extends GameManagerHelper { ); this.game.scene.modifiers = []; } - titlePhase.loadSaveSlot(-1); // Load the desired session data - this.game.phaseInterceptor.shiftPhase(); // Loading the save slot also ended TitlePhase, clean it up + await titlePhase.loadSaveSlot(-1); // Load the desired session data // Run through prompts for switching Pokemon, copied from classicModeHelper.ts if (this.game.scene.battleStyle === BattleStyle.SWITCH) { 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 0d357a75557..71d0aeee92e 100644 --- a/test/test-utils/phase-interceptor.ts +++ b/test/test-utils/phase-interceptor.ts @@ -1,445 +1,224 @@ +import type { PhaseString } from "#app/@types/phase-types"; import type { BattleScene } from "#app/battle-scene"; -import { Phase } from "#app/phase"; +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 type { AwaitableUiHandler } from "#ui/awaitable-ui-handler"; -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"; +// 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; -} +/** + * A Set containing phase names that will not be shown in the console when started. + * + * Used to reduce console noise from very repetitive phases. + */ +const blacklistedPhaseNames: ReadonlySet = new Set(["ActivatePriorityQueuePhase"]); -type PhaseInterceptorPhase = PhaseClass | PhaseString; - -interface PhaseStub { - start(): void; - endBySetMode: boolean; -} - -interface InProgressStub { - name: string; - callback(): void; - onError(error: any): void; -} - -interface onHoldStub { - name: string; - call(): void; -} +/** + * 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 or similar mechanism, + * and is currently waiting for the current phase to end. + * - `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: BattleScene; - // @ts-expect-error: initialized in `initPhases` - public phases: Record = {}; - public log: PhaseString[]; + private scene: BattleScene; /** - * TODO: This should not be an array; - * Our linear phase system means only 1 phase is ever started at once (if any) + * A log containing all phases having been executed in FIFO order. \ + * Entries are appended each time {@linkcode run} is called, and can be cleared manually with {@linkcode clearLogs}. */ - private onHold: onHoldStub[]; - private interval: NodeJS.Timeout; - private promptInterval: NodeJS.Timeout; - private intervalRun: NodeJS.Timeout; - private prompts: PromptHandler[]; - private inProgress?: InProgressStub; - private originalSetMode: UI["setMode"]; - private originalSuperEnd: Phase["end"]; - + public log: PhaseString[] = []; /** - * 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. + * 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 PHASES = [ - LoginPhase, - TitlePhase, - SelectGenderPhase, - NewBiomeEncounterPhase, - SelectStarterPhase, - PostSummonPhase, - SummonPhase, - ToggleDoublePositionPhase, - CheckSwitchPhase, - ShowAbilityPhase, - MessagePhase, - TurnInitPhase, - CommandPhase, - EnemyCommandPhase, - TurnStartPhase, - MovePhase, - MoveEffectPhase, - DamageAnimPhase, - FaintPhase, - BerryPhase, - TurnEndPhase, - BattleEndPhase, - EggLapsePhase, - SelectModifierPhase, - NextEncounterPhase, - NewBattlePhase, - VictoryPhase, - LearnMovePhase, - MoveEndPhase, - StatStageChangePhase, - ShinySparklePhase, - SelectTargetPhase, - UnavailablePhase, - QuietFormChangePhase, - SwitchPhase, - SwitchSummonPhase, - PartyHealPhase, - FormChangePhase, - EvolutionPhase, - EndEvolutionPhase, - LevelCapPhase, - AttemptRunPhase, - SelectBiomePhase, - PositionalTagPhase, - PokemonTransformPhase, - MysteryEncounterPhase, - MysteryEncounterOptionSelectedPhase, - MysteryEncounterBattlePhase, - MysteryEncounterRewardsPhase, - PostMysteryEncounterPhase, - RibbonModifierRewardPhase, - GameOverModifierRewardPhase, - ModifierRewardPhase, - PartyExpPhase, - ExpPhase, - EncounterPhase, - GameOverPhase, - UnlockPhase, - PostGameOverPhase, - RevivalBlessingPhase, - ]; + private state: StateType = "idling"; - private endBySetMode = [ - TitlePhase, - SelectGenderPhase, - CommandPhase, - SelectStarterPhase, - 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: BattleScene) { this.scene = scene; - this.onHold = []; - this.prompts = []; - this.clearLogs(); - this.startPromptHandler(); - this.initPhases(); - } - - /** - * Clears phase logs - */ - clearLogs() { - this.log = []; - } - - rejectAll(error) { - if (this.inProgress) { - clearInterval(this.promptInterval); - clearInterval(this.interval); - clearInterval(this.intervalRun); - this.inProgress.onError(error); - } + // 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 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. + * @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 + * @remarks + * This will not resolve for *any* reason until the target phase has been reached. + * @example + * await game.phaseInterceptor.to("MoveEffectPhase", false); */ - async to(phaseTo: PhaseInterceptorPhase, runTarget = true): Promise { - return new Promise(async (resolve, reject) => { - ErrorInterceptor.getInstance().add(this); - const targetName = typeof phaseTo === "string" ? phaseTo : phaseTo.name; - this.intervalRun = setInterval(async () => { - const currentPhase = this.onHold?.length && this.onHold[0]; - if (!currentPhase) { - // No current phase means the manager either hasn't started yet - // or we were interrupted by prompt; wait for phase to finish - return; - } + public async to(target: PhaseString | Constructor, runTarget = true): Promise { + this.target = typeof target === "string" ? target : (target.name as PhaseString); - // If current phase is different, run it and wait for it to finish. - if (currentPhase.name !== targetName) { - await this.run().catch(e => { - clearInterval(this.intervalRun); - return reject(e); - }); - return; - } + const pm = this.scene.phaseManager; - // Hit target phase; run it and resolve - clearInterval(this.intervalRun); - if (!runTarget) { - return resolve(); - } - await this.run().catch(e => { - clearInterval(this.intervalRun); - return reject(e); - }); - return resolve(); - }); - }); - } + // TODO: remove bangs once signature is updated + let currentPhase: Phase = pm.getCurrentPhase()!; - /** - * Method to run the current phase with an optional skip function. - * @returns A promise that resolves when the phase is run. - */ - private run(): Promise { - // @ts-expect-error: This is apparently mandatory to avoid a crash; review if this is needed - this.scene.moveAnimations = null; - return new Promise(async (resolve, reject) => { - ErrorInterceptor.getInstance().add(this); - const interval = setInterval(async () => { - const currentPhase = this.onHold.shift(); - if (currentPhase) { - clearInterval(interval); - this.inProgress = { - name: currentPhase.name, - callback: () => { - ErrorInterceptor.getInstance().remove(this); - resolve(); - }, - onError: error => reject(error), - }; - currentPhase.call(); - } - }); - }); - } + let didLog = false; - /** - * 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. - */ - shiftPhase(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.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 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 = () => this.startPhase.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 as PhaseString); - const instance = this.scene.phaseManager.getCurrentPhase(); - this.onHold.push({ - name: phase.name, - call: () => { - this.phases[phase.name].start.apply(instance); - }, - }); - } - - /** - * Method to end a phase and log it. - * @param phase - The phase to start. - */ - private 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 { - // TODO: remove the `!` in PR 6243 / after PR 6243 is merged - 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; - } - - /** - * 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 as AwaitableUiHandler)["awaitingActionInput"])) - ) { - const prompt = this.prompts.shift(); - if (prompt?.callback) { - prompt.callback(); + // 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") { + if (!didLog) { + this.doLog("PhaseInterceptor.to: Waiting for phase to end after being interrupted!"); + didLog = true; } + return false; } - } - }); - } - /** - * 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, - }); - } + currentPhase = pm.getCurrentPhase()!; + // TODO: Remove proof-of-concept error throw after signature update + if (!currentPhase) { + throw new Error("currentPhase is null after being started!"); + } - /** - * 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; + 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: 20_000 }, + ); + + // We hit the target; run as applicable and wrap up. + if (!runTarget) { + this.doLog(`PhaseInterceptor.to: Stopping before running ${this.target}`); + return; } - UI.prototype.setMode = this.originalSetMode; - Phase.prototype.end = this.originalSuperEnd; - clearInterval(this.promptInterval); - clearInterval(this.interval); - clearInterval(this.intervalRun); + + 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` to wait for phase end + * and undo various method stubs upon a test ending. \ + * However, since we now use {@linkcode vi.waitUntil} and {@linkcode vi.spyOn} to perform these tasks + * respectively, this function has become no longer needed. + * @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) { + if (!blacklistedPhaseNames.has(phaseName)) { + console.log(`%cStart Phase: ${phaseName}`, "color:green"); + } + this.log.push(phaseName); + } + + /** + * Clear all prior phase logs. + */ + public clearLogs(): void { + this.log = []; + } + + /** + * Wrapper function to add coral coloration to phase logs. + * @param args - Arguments to original logging function. + */ + private doLog(...args: unknown[]): void { + console.log(chalk.hex("#ff7f50")(...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..4169e946dbe --- /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("Test 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: "CommandPhase", + }) 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 from setModeInternal", 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 if current phase in `endBySetMode`", 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..4e381668001 --- /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.COMMAND, () => { + 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/vitest.setup.ts b/test/vitest.setup.ts index be35e18e2e9..2fdea7ea661 100644 --- a/test/vitest.setup.ts +++ b/test/vitest.setup.ts @@ -1,6 +1,7 @@ import "vitest-canvas-mock"; +import { PromptHandler } from "#test/test-utils/helpers/prompt-handler"; 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,13 +49,28 @@ vi.mock("i18next", async importOriginal => { return await importOriginal(); }); -global.testFailed = false; - beforeAll(() => { initTests(); + + process + .on("uncaughtException", err => { + clearInterval(PromptHandler.runInterval); + PromptHandler.runInterval = undefined; + throw err; + }) + .on("unhandledRejection", err => { + clearInterval(PromptHandler.runInterval); + PromptHandler.runInterval = undefined; + throw err; + }); }); afterAll(() => { global.server.close(); console.log("Closing i18n MSW server!"); }); + +afterEach(() => { + clearInterval(PromptHandler.runInterval); + PromptHandler.runInterval = undefined; +});