Added unit tests for phase interceptor

This commit is contained in:
Bertie690 2025-06-19 15:37:01 -04:00
parent 1bcad94568
commit 3ed587de6d
4 changed files with 251 additions and 14 deletions

View File

@ -400,6 +400,7 @@ export class PhaseManager {
*/ */
private startCurrentPhase(): void { private startCurrentPhase(): void {
if (!this.currentPhase) { if (!this.currentPhase) {
console.warn("trying to start null phase!");
return; return;
} }
console.log(`%cStart Phase ${this.currentPhase.phaseName}`, "color:green;"); console.log(`%cStart Phase ${this.currentPhase.phaseName}`, "color:green;");

View File

@ -3,7 +3,7 @@ import type { Phase } from "#app/phase";
import { UiMode } from "#enums/ui-mode"; import { UiMode } from "#enums/ui-mode";
import UI from "#app/ui/ui"; import UI from "#app/ui/ui";
import type { PhaseString } from "#app/@types/phase-types"; import type { PhaseString } from "#app/@types/phase-types";
import { vi } from "vitest"; import { vi, type MockInstance } from "vitest";
import { format } from "util"; import { format } from "util";
interface PromptHandler { interface PromptHandler {
@ -16,7 +16,7 @@ interface PromptHandler {
/** /**
* The PhaseInterceptor is a wrapper around the `BattleScene`'s {@linkcode PhaseManager}. * 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 { export default class PhaseInterceptor {
private scene: BattleScene; private scene: BattleScene;
@ -44,16 +44,18 @@ export default class PhaseInterceptor {
* Method to initialize various mocks for intercepting phases. * Method to initialize various mocks for intercepting phases.
*/ */
initMocks() { initMocks() {
const originalSetMode = UI.prototype.setMode; const originalSetMode = UI.prototype["setModeInternal"];
vi.spyOn(UI.prototype, "setMode").mockImplementation((mode, ...args) => // `any` assertion needed as we are mocking private property
this.setMode(originalSetMode, mode, ...args), 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 // Mock the private startCurrentPhase method to do nothing to let us
// start them manually ourselves. // start them manually ourselves.
this.scene.phaseManager["startCurrentPhase"] satisfies () => void; // typecheck in case function is renamed/removed vi.spyOn(this.scene.phaseManager as any, "startCurrentPhase").mockImplementation(() => {});
// @ts-expect-error - startCurrentPhase is private
vi.spyOn(this.scene.phaseManager, "startCurrentPhase").mockImplementation(() => {});
} }
/** /**
@ -72,6 +74,7 @@ export default class PhaseInterceptor {
* @param runTarget - Whether or not to run the target phase; default `true`. * @param runTarget - Whether or not to run the target phase; default `true`.
* @returns A promise that resolves when the transition is complete. * @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<void> { public async to(targetPhase: PhaseString, runTarget = true): Promise<void> {
let currentPhase = this.scene.phaseManager.getCurrentPhase(); let currentPhase = this.scene.phaseManager.getCurrentPhase();
while (!currentPhase?.is(targetPhase)) { while (!currentPhase?.is(targetPhase)) {
@ -126,12 +129,13 @@ export default class PhaseInterceptor {
* @param args - Additional arguments to pass to the original method. * @param args - Additional arguments to pass to the original method.
*/ */
private async setMode( private async setMode(
originalSetMode: typeof UI.prototype.setMode, originalSetMode: (typeof UI.prototype)["setModeInternal"],
mode: UiMode, args: Parameters<(typeof UI.prototype)["setModeInternal"]>,
...args: unknown[] ): ReturnType<(typeof UI.prototype)["setModeInternal"]> {
): ReturnType<typeof UI.prototype.setMode> { const mode = args[0];
console.log("setMode", `${UiMode[mode]} (=${mode})`, args); 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); this.doPromptCheck(mode);
return ret; return ret;
} }

View File

@ -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<mockPhase>[]) {
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();
});
});

View File

@ -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<mockPhase>[]) {
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([]);
});
});
});