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 {
if (!this.currentPhase) {
console.warn("trying to start null phase!");
return;
}
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 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<void> {
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<typeof UI.prototype.setMode> {
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;
}

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