diff --git a/src/init/init.ts b/src/init/init.ts index ba9738e2be8..72488bdc409 100644 --- a/src/init/init.ts +++ b/src/init/init.ts @@ -4,6 +4,7 @@ import { initEggMoves } from "#balance/egg-moves"; import { initPokemonPrevolutions, initPokemonStarters } from "#balance/pokemon-evolutions"; import { initSpecies } from "#balance/pokemon-species"; import { initChallenges } from "#data/challenge"; +import { allMoves } from "#data/data-lists"; import { initTrainerTypeDialogue } from "#data/dialogue"; import { initPokemonForms } from "#data/pokemon-forms"; import { initModifierPools } from "#modifiers/init-modifier-pools"; @@ -16,6 +17,10 @@ import { initStatsKeys } from "#ui/game-stats-ui-handler"; /** Initialize the game. */ export function initializeGame() { + if (allMoves.length > 0) { + console.warn("Game initialization ran twice during same session!"); + return; + } initModifierTypes(); initModifierPools(); initAchievements(); diff --git a/test/abilities/cud-chew.test.ts b/test/abilities/cud-chew.test.ts index 5f15de04cae..43e79057197 100644 --- a/test/abilities/cud-chew.test.ts +++ b/test/abilities/cud-chew.test.ts @@ -99,7 +99,7 @@ describe("Abilities - Cud Chew", () => { expect(abDisplaySpy.mock.calls[1][2]).toBe(false); // should display messgae - expect(game.textInterceptor.getLatestMessage()).toBe( + expect(game.textInterceptor.logs).toContain( i18next.t("battle:hpIsFull", { pokemonName: getPokemonNameWithAffix(farigiraf), }), diff --git a/test/test-utils/mocks/mock-console-log.ts b/test/test-utils/mocks/mock-console-log.ts deleted file mode 100644 index f54d41fea3e..00000000000 --- a/test/test-utils/mocks/mock-console-log.ts +++ /dev/null @@ -1,80 +0,0 @@ -const originalLog = console.log; -const originalError = console.error; -const originalDebug = console.debug; -const originalWarn = console.warn; - -const blacklist = ["Phaser", "variant icon does not exist", 'Texture "%s" not found']; -const whitelist = ["Phase"]; - -export class MockConsoleLog { - constructor( - private logDisabled = false, - private phaseText = false, - ) {} - private logs: any[] = []; - private notified: any[] = []; - - public log(...args) { - const argsStr = this.getStr(args); - this.logs.push(argsStr); - if (this.logDisabled && !this.phaseText) { - return; - } - if ((this.phaseText && !whitelist.some(b => argsStr.includes(b))) || blacklist.some(b => argsStr.includes(b))) { - return; - } - originalLog(args); - } - public error(...args) { - const argsStr = this.getStr(args); - this.logs.push(argsStr); - originalError(args); // Appelle le console.error originel - } - public debug(...args) { - const argsStr = this.getStr(args); - this.logs.push(argsStr); - if (this.logDisabled && !this.phaseText) { - return; - } - if (!whitelist.some(b => argsStr.includes(b)) || blacklist.some(b => argsStr.includes(b))) { - return; - } - originalDebug(args); - } - warn(...args) { - const argsStr = this.getStr(args); - this.logs.push(args); - if (this.logDisabled && !this.phaseText) { - return; - } - if (!whitelist.some(b => argsStr.includes(b)) || blacklist.some(b => argsStr.includes(b))) { - return; - } - originalWarn(args); - } - notify(msg) { - originalLog(msg); - this.notified.push(msg); - } - getLogs() { - return this.logs; - } - clearLogs() { - this.logs = []; - } - getStr(...args) { - return args - .map(arg => { - if (typeof arg === "object" && arg !== null) { - // Handle objects including arrays - return JSON.stringify(arg, (_key, value) => (typeof value === "bigint" ? value.toString() : value)); - } - if (typeof arg === "bigint") { - // Handle BigInt values - return arg.toString(); - } - return arg.toString(); - }) - .join(";"); - } -} diff --git a/test/test-utils/mocks/mock-console.ts b/test/test-utils/mocks/mock-console.ts new file mode 100644 index 00000000000..45bad0b2f12 --- /dev/null +++ b/test/test-utils/mocks/mock-console.ts @@ -0,0 +1,125 @@ +import { stderr, stdout } from "node:process"; +import util from "node:util"; +import chalk, { type ChalkInstance } from "chalk"; + +// TODO: Review this +const blacklist = [ + "variant icon does not exist", // Repetitive warnings about icons not found + 'Texture "%s" not found', // Repetitive warnings about textures not found + "type: 'Pokemon',", // Large Pokemon objects + "gameVersion: ", // Large session-data and system-data objects +]; +const whitelist = ["Start Phase"]; + +/** + * The {@linkcode MockConsole} is a wrapper around the global {@linkcode console} object. + * It automatically colors text and such. + */ +export class MockConsole extends console.Console { + /** + * A list of warnings that are queued to be displayed after all tests in the same file are finished. + */ + private static queuedWarnings: unknown[][] = []; + + /** + * Queue a warning to be printed after all tests in the same file are finished. + */ + // TODO: Add some warnings + public static queuePostTestWarning(...data: unknown[]): void { + MockConsole.queuedWarnings.push(data); + } + + constructor() { + super(stdout, stderr, false); + } + + /** + * Print all post-test warnings that have been queued, and then clears the queue. + */ + public static printPostTestWarnings() { + for (const data of MockConsole.queuedWarnings) { + console.warn(...data); + } + MockConsole.queuedWarnings = []; + } + + /** + * Check whether a given ste of data is in the blacklist to be barred from logging. + * @param data - The data being logged + * @returns Whether `data` is blacklisted from console logging + */ + private checkBlacklist(data: unknown[]): boolean { + const dataStr = this.getStr(data); + return !whitelist.some(b => dataStr.includes(b)) && blacklist.some(b => dataStr.includes(b)); + } + + public trace(...data: unknown[]) { + if (this.checkBlacklist(data)) { + return; + } + + // TODO: Figure out how to add color to the full trace text + super.trace(this.addColor(chalk.hex("#b700ff"), ...data)); + } + + public debug(...data: unknown[]) { + if (this.checkBlacklist(data)) { + return; + } + + super.debug(this.addColor(chalk.hex("#874600ff"), ...data)); + } + + public log(...data: unknown[]): void { + if (this.checkBlacklist(data)) { + return; + } + + if (typeof data[0] === "string" && data[0].includes("%c")) { + // Strip all CSS from console logs in place of green format + // (such as for "Start Phase" messages) + data[0] = data[0].replace("%c", ""); + super.log(this.addColor(chalk.green, data[0])); + } else if (data[0] === "[UI]") { + // Orange for UI debug messages + super.log(this.addColor(chalk.hex("#ffa500"), ...data)); + } else { + super.log(...data); + } + } + + public warn(...data: unknown[]) { + if (this.checkBlacklist(data)) { + return; + } + + super.warn(this.addColor(chalk.yellow, ...data)); + } + + public error(...data: unknown[]) { + if (this.checkBlacklist(data)) { + return; + } + + super.error(this.addColor(chalk.redBright, ...data)); + } + + /** + * Returns a human-readable string representation of `data`. + */ + private getStr(data: unknown) { + return util.inspect(data, { sorted: true, breakLength: 120 }); + } + + /** + * Prepends the given color to every argument in the given data. + * Also appends the white ANSI code as an extra argument, so that the added color does not leak to future messages. + * @param color - A Chalk instance used to color the output. + * @param data - The data that the color should be applied to. + * @returns A stringified copy of `data` with the color prepended to every argument. + * @todo Do we need to prepend it? + */ + private addColor(color: ChalkInstance, ...data: unknown[]): string[] { + return data.map(a => `${color(typeof a === "string" ? a : this.getStr(a))}`); + } +} diff --git a/test/test-utils/mocks/mocks-container/mock-text.ts b/test/test-utils/mocks/mocks-container/mock-text.ts index ad2fce80972..a64aa45ef80 100644 --- a/test/test-utils/mocks/mocks-container/mock-text.ts +++ b/test/test-utils/mocks/mocks-container/mock-text.ts @@ -1,4 +1,5 @@ import type { MockGameObject } from "#test/test-utils/mocks/mock-game-object"; +import type { TextInterceptor } from "#test/test-utils/text-interceptor"; import { UI } from "#ui/ui"; export class MockText implements MockGameObject { @@ -82,13 +83,14 @@ export class MockText implements MockGameObject { showText( text: string, - delay?: number | null, + _delay?: number | null, callback?: Function | null, - callbackDelay?: number | null, - prompt?: boolean | null, - promptDelay?: number | null, + _callbackDelay?: number | null, + _prompt?: boolean | null, + _promptDelay?: number | null, ) { - this.scene.messageWrapper.showText(text, delay, callback, callbackDelay, prompt, promptDelay); + // TODO: this is a very bad way to pass calls around + (this.scene.messageWrapper as TextInterceptor).showText(text); if (callback) { callback(); } @@ -96,13 +98,13 @@ export class MockText implements MockGameObject { showDialogue( keyOrText: string, - name: string | undefined, - delay: number | null = 0, + name: string, + _delay: number | null, callback: Function, - callbackDelay?: number, - promptDelay?: number, + _callbackDelay?: number, + _promptDelay?: number, ) { - this.scene.messageWrapper.showDialogue(keyOrText, name, delay, callback, callbackDelay, promptDelay); + (this.scene.messageWrapper as TextInterceptor).showDialogue(keyOrText, name); if (callback) { callback(); } diff --git a/test/test-utils/test-file-initialization.ts b/test/test-utils/test-file-initialization.ts index 631d3f9146b..578a6129adf 100644 --- a/test/test-utils/test-file-initialization.ts +++ b/test/test-utils/test-file-initialization.ts @@ -1,9 +1,7 @@ import { SESSION_ID_COOKIE_NAME } from "#app/constants"; -import { initializeGame } from "#app/init/init"; -import { initI18n } from "#plugins/i18n"; import { blobToString } from "#test/test-utils/game-manager-utils"; import { manageListeners } from "#test/test-utils/listeners-manager"; -import { MockConsoleLog } from "#test/test-utils/mocks/mock-console-log"; +import { MockConsole } from "#test/test-utils/mocks/mock-console"; import { mockContext } from "#test/test-utils/mocks/mock-context-canvas"; import { mockLocalStorage } from "#test/test-utils/mocks/mock-local-storage"; import { MockImage } from "#test/test-utils/mocks/mocks-container/mock-image"; @@ -12,40 +10,35 @@ import Phaser from "phaser"; import BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext"; import InputText from "phaser3-rex-plugins/plugins/inputtext"; -let wasInitialized = false; - /** - * Run initialization code upon starting a new file, both per-suite and per-instance oncess. + * Run per-suite initialization code upon starting a new file. */ export function initTests(): void { setupStubs(); - if (!wasInitialized) { - initTestFile(); - wasInitialized = true; - } - manageListeners(); } -/** - * Initialize various values at the beginning of each testing instance. - */ -function initTestFile(): void { - initI18n(); - initializeGame(); -} - /** * Setup various stubs for testing. * @todo Move this into a dedicated stub file instead of running it once per test instance + * @todo review these to see which are actually necessary * @todo Investigate why this resets on new test suite start */ function setupStubs(): void { - Object.defineProperty(window, "localStorage", { - value: mockLocalStorage(), - }); - Object.defineProperty(window, "console", { - value: new MockConsoleLog(false), + console.log(console instanceof MockConsole); + console.log(Phaser.GameObjects.Image instanceof MockImage); + Object.defineProperties(window, { + localStorage: { + value: mockLocalStorage(), + }, + console: { + value: new MockConsole(), + }, + matchMedia: { + value: () => ({ + matches: false, + }), + }, }); Object.defineProperty(document, "fonts", { writable: true, @@ -69,11 +62,6 @@ function setupStubs(): void { navigator.getGamepads = () => []; setCookie(SESSION_ID_COOKIE_NAME, "fake_token"); - window.matchMedia = () => - ({ - matches: false, - }) as any; - /** * Sets this object's position relative to another object with a given offset * @param guideObject - The {@linkcode Phaser.GameObjects.GameObject} to base the position off of diff --git a/test/test-utils/text-interceptor.ts b/test/test-utils/text-interceptor.ts index 36a5db4c78d..2e39fe03449 100644 --- a/test/test-utils/text-interceptor.ts +++ b/test/test-utils/text-interceptor.ts @@ -1,39 +1,48 @@ +import type { BattleScene } from "#app/battle-scene"; +import chalk from "chalk"; + /** - * Class will intercept any text or dialogue message calls and log them for test purposes + * The {@linkcode TextInterceptor} is a wrapper class that intercepts and logs any messages + * that would be displayed on-screen. */ export class TextInterceptor { - private scene; - public logs: string[] = []; - constructor(scene) { - this.scene = scene; + /** A log containing messages having been displayed on screen, sorted in FIFO order. */ + public readonly logs: string[] = []; + + constructor(scene: BattleScene) { + // @ts-expect-error: Find another more sanitary way of doing this scene.messageWrapper = this; } - showText( - text: string, - _delay?: number, - _callback?: Function, - _callbackDelay?: number, - _prompt?: boolean, - _promptDelay?: number, - ): void { - console.log(text); + /** Clear the current content of the TextInterceptor. */ + public clearLogs(): void { + this.logs.splice(0); + } + + showText(text: string): void { + console.log(this.formatText(text)); this.logs.push(text); } - showDialogue( - text: string, - name: string, - _delay?: number, - _callback?: Function, - _callbackDelay?: number, - _promptDelay?: number, - ): void { - console.log(name, text); + showDialogue(text: string, name: string): void { + console.log(this.formatText(`${name}: \n${text}`)); this.logs.push(name, text); } - getLatestMessage(): string { - return this.logs.pop() ?? ""; + /** + * Format text to be displayed to the test console, as follows: + * 1. Replaces new lines and new text boxes (marked by `$`) with indented new lines. + * 2. Removes all `@c{}`, `@d{}`, `@s{}`, and `@f{}` flags from the text. + * 3. Makes text blue + * @param text - The unformatted text + * @returns The formatted text + */ + private formatText(text: string): string { + return chalk.blue( + text + .replace(/\n/g, "\n ") + .replace(/\$/g, "\n ") + .replace(/@\w{.*?}/g, ""), + ); } } diff --git a/test/vitest.setup.ts b/test/vitest.setup.ts index be35e18e2e9..dcc0074a680 100644 --- a/test/vitest.setup.ts +++ b/test/vitest.setup.ts @@ -1,4 +1,6 @@ import "vitest-canvas-mock"; +import { initializeGame } from "#app/init/init"; +import { MockConsole } from "#test/test-utils/mocks/mock-console"; import { initTests } from "#test/test-utils/test-file-initialization"; import { afterAll, beforeAll, vi } from "vitest"; @@ -14,6 +16,20 @@ vi.mock("#app/overrides", async importOriginal => { } satisfies typeof import("#app/overrides"); }); +//#region Mocking + +/** Mock the override import to always return default values, ignoring any custom overrides. */ +vi.mock("#app/overrides", async importOriginal => { + const { defaultOverrides } = await importOriginal(); + + return { + default: defaultOverrides, + // Export `defaultOverrides` as a *copy*. + // This ensures we can easily reset `overrides` back to its default values after modifying it. + defaultOverrides: { ...defaultOverrides }, + } satisfies typeof import("#app/overrides"); +}); + /** * This is a hacky way to mock the i18n backend requests (with the help of {@link https://mswjs.io/ | msw}). * The reason to put it inside of a mock is to elevate it. @@ -30,8 +46,12 @@ vi.mock("i18next", async importOriginal => { const filename = req.params[0]; try { - const json = await import(`../public/locales/en/${req.params[0]}`); - console.log("Loaded locale", filename); + const localeFiles = import.meta.glob("../public/locales/en/**/*.json", { eager: true }); + const json = localeFiles[`../public/locales/en/${filename}`] || {}; + // + if (import.meta.env.VITE_I18N_DEBUG === "1") { + console.log("Loaded locale", filename); + } return HttpResponse.json(json); } catch (err) { console.log(`Failed to load locale ${filename}!`, err); @@ -48,13 +68,25 @@ vi.mock("i18next", async importOriginal => { return await importOriginal(); }); +/** Ensure that i18n is initialized on all calls. */ +// TODO: Initialize i18n directly on import instead of initializing it during importing of trainer code +vi.mock("#app/plugins/i18n", async importOriginal => { + const importedStuff = await importOriginal(); + const { initI18n } = importedStuff; + await initI18n(); + return importedStuff; +}); + global.testFailed = false; +initializeGame(); + beforeAll(() => { initTests(); }); afterAll(() => { global.server.close(); + MockConsole.printPostTestWarnings(); console.log("Closing i18n MSW server!"); });