diff --git a/src/phase-manager.ts b/src/phase-manager.ts index e6e0b247405..3f465f7b5fb 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -400,6 +400,7 @@ export class PhaseManager { */ private startCurrentPhase(): void { if (!this.currentPhase) { + console.warn("trying to start null phase!"); return; } console.log(`%cStart Phase ${this.currentPhase.phaseName}`, "color:green;"); diff --git a/test/testUtils/phaseInterceptor.ts b/test/testUtils/phaseInterceptor.ts index c196a74451a..03914c39d42 100644 --- a/test/testUtils/phaseInterceptor.ts +++ b/test/testUtils/phaseInterceptor.ts @@ -3,7 +3,7 @@ import type { Phase } from "#app/phase"; import { UiMode } from "#enums/ui-mode"; import UI from "#app/ui/ui"; import type { PhaseString } from "#app/@types/phase-types"; -import { vi } from "vitest"; +import { vi, type MockInstance } from "vitest"; import { format } from "util"; interface PromptHandler { @@ -16,7 +16,7 @@ interface PromptHandler { /** * The PhaseInterceptor is a wrapper around the `BattleScene`'s {@linkcode PhaseManager}. - * It allows tests to exert finer control over the phase system, providing logging, + * It allows tests to exert finer control over the phase system, providing logging, manual advancing, etc etc. */ export default class PhaseInterceptor { private scene: BattleScene; @@ -44,16 +44,18 @@ export default class PhaseInterceptor { * Method to initialize various mocks for intercepting phases. */ initMocks() { - const originalSetMode = UI.prototype.setMode; - vi.spyOn(UI.prototype, "setMode").mockImplementation((mode, ...args) => - this.setMode(originalSetMode, mode, ...args), - ); + const originalSetMode = UI.prototype["setModeInternal"]; + // `any` assertion needed as we are mocking private property + const uiSpy = vi.spyOn(UI.prototype as any, "setModeInternal") as MockInstance< + (typeof UI.prototype)["setModeInternal"] + >; + uiSpy.mockImplementation(async (...args) => { + this.setMode(originalSetMode, args); + }); // Mock the private startCurrentPhase method to do nothing to let us // start them manually ourselves. - this.scene.phaseManager["startCurrentPhase"] satisfies () => void; // typecheck in case function is renamed/removed - // @ts-expect-error - startCurrentPhase is private - vi.spyOn(this.scene.phaseManager, "startCurrentPhase").mockImplementation(() => {}); + vi.spyOn(this.scene.phaseManager as any, "startCurrentPhase").mockImplementation(() => {}); } /** @@ -72,6 +74,7 @@ export default class PhaseInterceptor { * @param runTarget - Whether or not to run the target phase; default `true`. * @returns A promise that resolves when the transition is complete. */ + // TODO: Does this need to be asynchronous? public async to(targetPhase: PhaseString, runTarget = true): Promise { let currentPhase = this.scene.phaseManager.getCurrentPhase(); while (!currentPhase?.is(targetPhase)) { @@ -126,12 +129,13 @@ export default class PhaseInterceptor { * @param args - Additional arguments to pass to the original method. */ private async setMode( - originalSetMode: typeof UI.prototype.setMode, - mode: UiMode, - ...args: unknown[] - ): ReturnType { + originalSetMode: (typeof UI.prototype)["setModeInternal"], + args: Parameters<(typeof UI.prototype)["setModeInternal"]>, + ): ReturnType<(typeof UI.prototype)["setModeInternal"]> { + const mode = args[0]; + console.log("setMode", `${UiMode[mode]} (=${mode})`, args); - const ret = originalSetMode.apply(this.scene.ui, [mode, ...args]); + const ret = originalSetMode.apply(this.scene.ui, [args]); this.doPromptCheck(mode); return ret; } diff --git a/test/utils/phase-interceptor-prompts.test.ts b/test/utils/phase-interceptor-prompts.test.ts new file mode 100644 index 00000000000..b6397eb5200 --- /dev/null +++ b/test/utils/phase-interceptor-prompts.test.ts @@ -0,0 +1,106 @@ +import type { PhaseString } from "#app/@types/phase-types"; +import { globalScene } from "#app/global-scene"; +import { Phase } from "#app/phase"; +import type { Constructor } from "#app/utils/common"; +import { UiMode } from "#enums/ui-mode"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +abstract class mockPhase extends Phase { + public override readonly phaseName: any; + + override start() { + this.end(); + } +} + +class testDialogueUiPhase extends mockPhase { + public readonly phaseName = "testDialogueUiPhase"; + override start() { + void globalScene.ui.setMode(UiMode.TEST_DIALOGUE); + super.start(); + } +} + +class titleUiPhase extends mockPhase { + public readonly phaseName = "titleUiPhase"; + override start() { + void globalScene.ui.setMode(UiMode.TITLE); + super.start(); + } +} + +class dualUiPhase extends mockPhase { + public readonly phaseName = "dualUiPhase"; + override start() { + void globalScene.ui.setMode(UiMode.TEST_DIALOGUE); + void globalScene.ui.setMode(UiMode.TITLE); + super.start(); + } +} + +describe("Utils - Phase Interceptor Prompts", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + setPhases(testDialogueUiPhase, titleUiPhase, dualUiPhase); + }); + + function setPhases(...phases: Constructor[]) { + game.scene.phaseManager.clearAllPhases(); + game.scene.phaseManager.phaseQueue = phases.map(m => new m()); + game.scene.phaseManager.shiftPhase(); // start the thing going + } + + /** Wrapper function to make TS not complain about `PhaseString` stuff */ + function to(phaseName: string, runTarget = false) { + return game.phaseInterceptor.to(phaseName as unknown as PhaseString, runTarget); + } + + function onNextPrompt(target: string, mode: UiMode, callback: () => void, expireFn?: () => boolean) { + game.onNextPrompt(target as unknown as PhaseString, mode, callback, expireFn); + } + + it("should run the specified callback when the given ui mode is reached", async () => { + const callback = vi.fn(); + onNextPrompt("testDialogueUiPhase", UiMode.TEST_DIALOGUE, () => callback()); + + await to("testDialogueUiPhase"); + expect(callback).toHaveBeenCalled(); + }); + + it("should not run callback if wrong ui mode", async () => { + const callback = vi.fn(); + onNextPrompt("testDialogueUiPhase", UiMode.TITLE, () => callback()); + + await to("testDialogueUiPhase"); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should not run callback if wrong phase", async () => { + const callback = vi.fn(); + onNextPrompt("titleUiPhase", UiMode.TITLE, () => callback()); + + await to("testDialogueUiPhase"); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should work in succession", async () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + onNextPrompt("dualUiPhase", UiMode.TEST_DIALOGUE, () => callback1()); + onNextPrompt("dualUiPhase", UiMode.TITLE, () => callback2()); + + await to("dualUiPhase"); + expect(callback1).not.toHaveBeenCalled(); + }); +}); diff --git a/test/utils/phase-interceptor.test.ts b/test/utils/phase-interceptor.test.ts new file mode 100644 index 00000000000..6e1422fa94a --- /dev/null +++ b/test/utils/phase-interceptor.test.ts @@ -0,0 +1,126 @@ +import type { PhaseString } from "#app/@types/phase-types"; +import { Phase } from "#app/phase"; +import type { Constructor } from "#app/utils/common"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; + +let phaserGame: Phaser.Game; +let game: GameManager; + +abstract class mockPhase extends Phase { + public override readonly phaseName: any; + + override start() { + console.log(this.phaseName); + this.end(); + } +} + +class normalPhase extends mockPhase { + public readonly phaseName = "normalPhase"; +} + +class applePhase extends mockPhase { + public readonly phaseName = "applePhase"; +} + +class oneSecTimerPhase extends mockPhase { + public readonly phaseName = "oneSecTimerPhase"; + override start() { + setInterval(() => { + super.start(); + }, 1000); + } +} + +class unshifterPhase extends mockPhase { + public readonly phaseName = "unshifterPhase"; + override start() { + game.scene.phaseManager.unshiftPhase(new normalPhase() as unknown as Phase); + game.scene.phaseManager.unshiftPhase(new applePhase() as unknown as Phase); + super.start(); + } +} + +// reduce timeout so failed tests don't hang as long +describe("Utils - Phase Interceptor", { timeout: 4000 }, () => { + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + setPhases(normalPhase, applePhase, oneSecTimerPhase, unshifterPhase, normalPhase); + }); + + function setPhases(...phases: Constructor[]) { + game.scene.phaseManager.clearAllPhases(); + game.scene.phaseManager.phaseQueue = phases.map(m => new m()); + 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 ?? "null"; + } + + /** Wrapper function to make TS not complain about `PhaseString` stuff */ + function to(phaseName: string, runTarget = true) { + return game.phaseInterceptor.to(phaseName as unknown as PhaseString, runTarget); + } + + describe("to", () => { + it("should run the specified phase and halt after it ends", async () => { + await to("normalPhase"); + expect(getCurrentPhaseName()).toBe("applePhase"); + expect(getQueuedPhases()).toEqual(["oneSecTimerPhase", "unshifterPhase", "normalPhase"]); + expect(game.phaseInterceptor.log).toEqual(["normalPhase"]); + }); + + it("should run to the specified phase without starting/logging", async () => { + await to("normalPhase", false); + expect(getCurrentPhaseName()).toBe("normalPhase"); + expect(getQueuedPhases()).toEqual(["applePhase", "oneSecTimerPhase", "unshifterPhase", "normalPhase"]); + expect(game.phaseInterceptor.log).toHaveLength(0); + }); + + it("should start all phases between start and target", async () => { + await to("oneSecTimerPhase"); + expect(getQueuedPhases()).toEqual(["unshifterPhase", "normalPhase"]); + expect(game.phaseInterceptor.log).toEqual(["normalPhase", "applePhase", "oneSecTimerPhase"]); + }); + + it("should work on newly unshifted phases", async () => { + setPhases(unshifterPhase); // adds normalPhase and applePhase to queue + await to("applePhase"); + expect(game.phaseInterceptor.log).toEqual(["unshifterPhase", "normalPhase", "applePhase"]); + }); + + it("should wait until phase finishes before starting next", async () => { + setPhases(oneSecTimerPhase, applePhase); + setTimeout(() => expect(getCurrentPhaseName()).toBe("oneSecTimerPhase"), 500); + await to("applePhase"); + }); + }); + + describe("shift", () => { + it("should skip the next phase without starting", async () => { + expect(getCurrentPhaseName()).toBe("normalPhase"); + expect(getQueuedPhases()).toEqual(["applePhase", "oneSecTimerPhase", "unshifterPhase", "normalPhase"]); + + game.phaseInterceptor.shiftPhase(); + + expect(getCurrentPhaseName()).toBe("applePhase"); + expect(getQueuedPhases()).toEqual(["oneSecTimerPhase", "unshifterPhase", "normalPhase"]); + expect(game.phaseInterceptor.log).toEqual([]); + }); + }); +});