mirror of
https://github.com/pagefaultgames/pokerogue.git
synced 2025-08-11 18:09:29 +02:00
rest of phase interceptor changes
This commit is contained in:
parent
f76d12f430
commit
5d1e13139c
@ -3,7 +3,7 @@ import { MoveId } from "#enums/move-id";
|
|||||||
import { SpeciesId } from "#enums/species-id";
|
import { SpeciesId } from "#enums/species-id";
|
||||||
import { GameManager } from "#test/test-utils/game-manager";
|
import { GameManager } from "#test/test-utils/game-manager";
|
||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("{{description}}", () => {
|
describe("{{description}}", () => {
|
||||||
let phaserGame: Phaser.Game;
|
let phaserGame: Phaser.Game;
|
||||||
@ -15,10 +15,6 @@ describe("{{description}}", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
game.phaseInterceptor.restoreOg();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
game = new GameManager(phaserGame);
|
game = new GameManager(phaserGame);
|
||||||
game.override
|
game.override
|
||||||
|
@ -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);
|
this.scene.remove(LoadingScene.KEY);
|
||||||
initGameSpeed.apply(this);
|
initGameSpeed.apply(this);
|
||||||
this.inputController = new InputsController();
|
this.inputController = new InputsController();
|
||||||
@ -410,6 +426,7 @@ export class BattleScene extends SceneBase {
|
|||||||
this.ui?.update();
|
this.ui?.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Split this up into multiple sub-methods
|
||||||
launchBattle() {
|
launchBattle() {
|
||||||
this.arenaBg = this.add.sprite(0, 0, "plains_bg");
|
this.arenaBg = this.add.sprite(0, 0, "plains_bg");
|
||||||
this.arenaBg.setName("sprite-arena-bg");
|
this.arenaBg.setName("sprite-arena-bg");
|
||||||
@ -584,8 +601,6 @@ export class BattleScene extends SceneBase {
|
|||||||
|
|
||||||
this.party = [];
|
this.party = [];
|
||||||
|
|
||||||
const loadPokemonAssets = [];
|
|
||||||
|
|
||||||
this.arenaPlayer = new ArenaBase(true);
|
this.arenaPlayer = new ArenaBase(true);
|
||||||
this.arenaPlayer.setName("arena-player");
|
this.arenaPlayer.setName("arena-player");
|
||||||
this.arenaPlayerTransition = new ArenaBase(true);
|
this.arenaPlayerTransition = new ArenaBase(true);
|
||||||
@ -600,6 +615,8 @@ export class BattleScene extends SceneBase {
|
|||||||
this.arenaNextEnemy.setVisible(false);
|
this.arenaNextEnemy.setVisible(false);
|
||||||
|
|
||||||
for (const a of [this.arenaPlayer, this.arenaPlayerTransition, this.arenaEnemy, this.arenaNextEnemy]) {
|
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) {
|
if (a instanceof Phaser.GameObjects.Sprite) {
|
||||||
a.setOrigin(0, 0);
|
a.setOrigin(0, 0);
|
||||||
}
|
}
|
||||||
@ -640,26 +657,16 @@ export class BattleScene extends SceneBase {
|
|||||||
|
|
||||||
this.reset(false, false, true);
|
this.reset(false, false, true);
|
||||||
|
|
||||||
|
// Initialize UI-related aspects and then start the login phase.
|
||||||
const ui = new UI();
|
const ui = new UI();
|
||||||
this.uiContainer.add(ui);
|
this.uiContainer.add(ui);
|
||||||
|
|
||||||
this.ui = ui;
|
this.ui = ui;
|
||||||
|
|
||||||
ui.setup();
|
ui.setup();
|
||||||
|
|
||||||
const defaultMoves = [MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE];
|
this.phaseManager.pushNew("LoginPhase");
|
||||||
|
this.phaseManager.pushNew("TitlePhase");
|
||||||
|
|
||||||
Promise.all([
|
this.phaseManager.shiftPhase();
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initSession(): void {
|
initSession(): void {
|
||||||
@ -1153,6 +1160,7 @@ export class BattleScene extends SceneBase {
|
|||||||
return this.currentBattle?.randSeedInt(range, min);
|
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 {
|
reset(clearScene = false, clearData = false, reloadI18n = false): void {
|
||||||
if (clearData) {
|
if (clearData) {
|
||||||
this.gameData = new GameData();
|
this.gameData = new GameData();
|
||||||
|
@ -370,6 +370,9 @@ export class PhaseManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.currentPhase = this.phaseQueue.shift() ?? null;
|
this.currentPhase = this.phaseQueue.shift() ?? null;
|
||||||
|
if (!this.currentPhase) {
|
||||||
|
throw new Error("No phases in queue; aborting");
|
||||||
|
}
|
||||||
|
|
||||||
const unactivatedConditionalPhases: [() => boolean, Phase][] = [];
|
const unactivatedConditionalPhases: [() => boolean, Phase][] = [];
|
||||||
// Check if there are any conditional phases queued
|
// Check if there are any conditional phases queued
|
||||||
@ -389,12 +392,26 @@ export class PhaseManager {
|
|||||||
}
|
}
|
||||||
this.conditionalQueue.push(...unactivatedConditionalPhases);
|
this.conditionalQueue.push(...unactivatedConditionalPhases);
|
||||||
|
|
||||||
if (this.currentPhase) {
|
this.startCurrentPhase();
|
||||||
console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;");
|
|
||||||
this.currentPhase.start();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
overridePhase(phase: Phase): boolean {
|
||||||
if (this.standbyPhase) {
|
if (this.standbyPhase) {
|
||||||
return false;
|
return false;
|
||||||
@ -402,8 +419,7 @@ export class PhaseManager {
|
|||||||
|
|
||||||
this.standbyPhase = this.currentPhase;
|
this.standbyPhase = this.currentPhase;
|
||||||
this.currentPhase = phase;
|
this.currentPhase = phase;
|
||||||
console.log(`%cStart Phase ${phase.constructor.name}`, "color:green;");
|
this.startCurrentPhase();
|
||||||
phase.start();
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
});
|
|
@ -87,17 +87,6 @@ function getTestRunStarters(seed: string, species?: SpeciesId[]): Starter[] {
|
|||||||
return starters;
|
return starters;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function waitUntil(truth): Promise<unknown> {
|
|
||||||
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
|
* Useful for populating party, wave index, etc. without having to spin up and run through an entire EncounterPhase
|
||||||
*/
|
*/
|
||||||
|
@ -47,12 +47,14 @@ export class GameWrapper {
|
|||||||
public scene: BattleScene;
|
public scene: BattleScene;
|
||||||
|
|
||||||
constructor(phaserGame: Phaser.Game, bypassLogin: boolean) {
|
constructor(phaserGame: Phaser.Game, bypassLogin: boolean) {
|
||||||
|
// TODO: Figure out how to actually set RNG states correctly
|
||||||
Phaser.Math.RND.sow(["test"]);
|
Phaser.Math.RND.sow(["test"]);
|
||||||
// vi.spyOn(Utils, "apiFetch", "get").mockReturnValue(fetch);
|
// vi.spyOn(Utils, "apiFetch", "get").mockReturnValue(fetch);
|
||||||
if (bypassLogin) {
|
if (bypassLogin) {
|
||||||
vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(true);
|
vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(true);
|
||||||
}
|
}
|
||||||
this.game = phaserGame;
|
this.game = phaserGame;
|
||||||
|
// TODO: Move these mocks elsewhere
|
||||||
MoveAnim.prototype.getAnim = () => ({
|
MoveAnim.prototype.getAnim = () => ({
|
||||||
frames: {},
|
frames: {},
|
||||||
});
|
});
|
||||||
@ -71,10 +73,16 @@ export class GameWrapper {
|
|||||||
PokedexMonContainer.prototype.remove = MockContainer.prototype.remove;
|
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<void> {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
this.injectMandatory();
|
this.injectMandatory();
|
||||||
this.scene.preload?.();
|
this.scene.preload();
|
||||||
this.scene.create();
|
this.scene.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
156
test/test-utils/helpers/prompt-handler.ts
Normal file
156
test/test-utils/helpers/prompt-handler.ts
Normal file
@ -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<PhaseString> = [
|
||||||
|
"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<typeof this.originalSetModeInternal>) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
43
test/test-utils/interval-helper.ts
Normal file
43
test/test-utils/interval-helper.ts
Normal file
@ -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<TArgs extends any[]>(
|
||||||
|
...args: Parameters<typeof setTimeout<TArgs>>
|
||||||
|
): ReturnType<typeof setTimeout<TArgs>>;
|
||||||
|
export function setTempTimeout(...args: Parameters<typeof setTimeout>): ReturnType<typeof setTimeout>;
|
||||||
|
export function setTempTimeout(...args: Parameters<typeof setTimeout>): ReturnType<typeof setTimeout> {
|
||||||
|
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<TArgs extends any[]>(
|
||||||
|
...args: Parameters<typeof setInterval<TArgs>>
|
||||||
|
): ReturnType<typeof setInterval<TArgs>>;
|
||||||
|
export function setTempInterval(...args: Parameters<typeof setInterval>): ReturnType<typeof setInterval>;
|
||||||
|
export function setTempInterval(...args: Parameters<typeof setInterval>): ReturnType<typeof setInterval> {
|
||||||
|
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);
|
||||||
|
}
|
11
test/test-utils/mocks/mock-phase.ts
Normal file
11
test/test-utils/mocks/mock-phase.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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 { UiMode } from "#enums/ui-mode";
|
||||||
import { AttemptRunPhase } from "#phases/attempt-run-phase";
|
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports
|
||||||
import { BattleEndPhase } from "#phases/battle-end-phase";
|
import type { GameManager } from "#test/test-utils/game-manager";
|
||||||
import { BerryPhase } from "#phases/berry-phase";
|
import type { PromptHandler } from "#test/test-utils/helpers/prompt-handler";
|
||||||
import { CheckSwitchPhase } from "#phases/check-switch-phase";
|
import { setTimeout } from "timers/promises";
|
||||||
import { CommandPhase } from "#phases/command-phase";
|
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports
|
||||||
import { DamageAnimPhase } from "#phases/damage-anim-phase";
|
import { format } from "util";
|
||||||
import { EggLapsePhase } from "#phases/egg-lapse-phase";
|
import chalk from "chalk";
|
||||||
import { EncounterPhase } from "#phases/encounter-phase";
|
import { vi } from "vitest";
|
||||||
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";
|
|
||||||
|
|
||||||
export interface PromptHandler {
|
/**
|
||||||
phaseTarget?: string;
|
* The interceptor's current state.
|
||||||
mode?: UiMode;
|
* Possible values are the following:
|
||||||
callback?: () => void;
|
* - `running`: The interceptor is currently running a phase.
|
||||||
expireFn?: () => void;
|
* - `interrupted`: The interceptor has been interrupted by a UI prompt and is waiting for the caller to end it.
|
||||||
awaitingActionInput?: boolean;
|
* - `idling`: The interceptor is not currently running a phase and is ready to start a new one.
|
||||||
}
|
*/
|
||||||
|
type StateType = "running" | "interrupted" | "idling";
|
||||||
type PhaseInterceptorPhase = PhaseClass | PhaseString;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export class PhaseInterceptor {
|
||||||
public scene;
|
private scene: BattleScene;
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of phases with their corresponding start methods.
|
* A log of phases having been executed.
|
||||||
*
|
* Entries are appended each time {@linkcode run} is called, and can be cleared with {@linkcode clearLogs}.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
private PHASES = [
|
public log: PhaseString[] = [];
|
||||||
[LoginPhase, this.startPhase],
|
/**
|
||||||
[TitlePhase, this.startPhase],
|
* The interceptor's current state.
|
||||||
[SelectGenderPhase, this.startPhase],
|
* Possible values are the following:
|
||||||
[NewBiomeEncounterPhase, this.startPhase],
|
* - `running`: The interceptor is currently running a phase.
|
||||||
[SelectStarterPhase, this.startPhase],
|
* - `interrupted`: The interceptor has been interrupted by a UI prompt and is waiting for the caller to end it.
|
||||||
[PostSummonPhase, this.startPhase],
|
* - `idling`: The interceptor is not currently running a phase and is ready to start a new one.
|
||||||
[SummonPhase, this.startPhase],
|
*/
|
||||||
[ToggleDoublePositionPhase, this.startPhase],
|
private state: StateType = "idling";
|
||||||
[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],
|
|
||||||
];
|
|
||||||
|
|
||||||
private endBySetMode = [
|
private target: PhaseString;
|
||||||
TitlePhase,
|
|
||||||
SelectGenderPhase,
|
|
||||||
CommandPhase,
|
|
||||||
SelectModifierPhase,
|
|
||||||
MysteryEncounterPhase,
|
|
||||||
PostMysteryEncounterPhase,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor to initialize the scene and properties, and to start the phase handling.
|
* 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.scene = scene;
|
||||||
this.onHold = [];
|
// Mock the private startCurrentPhase method to toggle `isRunning` rather than actually starting anything
|
||||||
this.prompts = [];
|
vi.spyOn(this.scene.phaseManager as any, "startCurrentPhase").mockImplementation(() => {
|
||||||
this.clearLogs();
|
this.state = "idling";
|
||||||
this.startPromptHandler();
|
});
|
||||||
this.initPhases();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Phase>, runTarget = true): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
* Clears phase logs
|
||||||
*/
|
*/
|
||||||
clearLogs() {
|
public clearLogs(): void {
|
||||||
this.log = [];
|
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.
|
* Wrapper function to add green coloration to phase logs.
|
||||||
* @param phaseFrom - The phase to start from.
|
* @param args - Arguments to original logging function.
|
||||||
* @returns The instance of the PhaseInterceptor.
|
|
||||||
*/
|
*/
|
||||||
runFrom(phaseFrom: PhaseInterceptorPhase): PhaseInterceptor {
|
private doLog(...args: unknown[]): void {
|
||||||
this.phaseFrom = phaseFrom;
|
// Use chalk highlighting instead of normal green due to Node.js not respecting `%c` CSS color setting
|
||||||
return this;
|
console.log(chalk.green(...args));
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
153
test/test-utils/tests/helpers/prompt-handler.test.ts
Normal file
153
test/test-utils/tests/helpers/prompt-handler.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
62
test/test-utils/tests/phase-interceptor/integration.test.ts
Normal file
62
test/test-utils/tests/phase-interceptor/integration.test.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
150
test/test-utils/tests/phase-interceptor/unit.test.ts
Normal file
150
test/test-utils/tests/phase-interceptor/unit.test.ts
Normal file
@ -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<mockPhase>, ...phases: Constructor<mockPhase>[]) {
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
25
test/test-utils/tests/timeout-reset.test.ts
Normal file
25
test/test-utils/tests/timeout-reset.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
@ -1,6 +1,7 @@
|
|||||||
import "vitest-canvas-mock";
|
import "vitest-canvas-mock";
|
||||||
|
import { clearAllTimeouts } from "#test/test-utils/interval-helper";
|
||||||
import { initTests } from "#test/test-utils/test-file-initialization";
|
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. */
|
/** Set the timezone to UTC for tests. */
|
||||||
|
|
||||||
@ -48,8 +49,6 @@ vi.mock("i18next", async importOriginal => {
|
|||||||
return await importOriginal();
|
return await importOriginal();
|
||||||
});
|
});
|
||||||
|
|
||||||
global.testFailed = false;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
initTests();
|
initTests();
|
||||||
});
|
});
|
||||||
@ -58,3 +57,17 @@ afterAll(() => {
|
|||||||
global.server.close();
|
global.server.close();
|
||||||
console.log("Closing i18n MSW server!");
|
console.log("Closing i18n MSW server!");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearAllTimeouts();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("uncaughtException", err => {
|
||||||
|
clearAllTimeouts();
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("unhandledRejection", err => {
|
||||||
|
clearAllTimeouts();
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user