From 4b447073a7efd659f05d7f89a4107f828a5ad50a Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Mon, 28 Jul 2025 11:20:31 -0400 Subject: [PATCH] Fixed log message to not be overly verbose --- global.d.ts | 8 +++ .../matchers/to-equal-array-unsorted.ts | 18 +++--- .../matchers/to-have-battler-tag.ts | 6 +- .../test-utils/matchers/to-have-stat-stage.ts | 2 +- .../matchers/to-have-status-effect.ts | 25 +++++--- test/test-utils/matchers/to-have-terrain.ts | 3 +- test/test-utils/matchers/to-have-types.ts | 2 +- test/test-utils/matchers/to-have-used-move.ts | 8 ++- test/test-utils/matchers/to-have-weather.ts | 2 +- test/test-utils/string-utils.ts | 63 ++++++++++++++++--- 10 files changed, 102 insertions(+), 35 deletions(-) diff --git a/global.d.ts b/global.d.ts index 6741a15864c..8b79d966e3c 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,3 +1,4 @@ +import type { AnyFn } from "#types/type-helpers"; import type { SetupServerApi } from "msw/node"; declare global { @@ -9,4 +10,11 @@ declare global { * To set up your own server in a test see `game-data.test.ts` */ var server: SetupServerApi; + + // Overloads for `Function.apply` and `Function.call` to add type safety on matching argument types + interface Function { + apply(this: T, thisArg: ThisParameterType, argArray: Parameters): ReturnType; + + call(this: T, thisArg: ThisParameterType, ...argArray: Parameters): ReturnType; + } } diff --git a/test/test-utils/matchers/to-equal-array-unsorted.ts b/test/test-utils/matchers/to-equal-array-unsorted.ts index 7f43908937d..4ed2eeb66fb 100644 --- a/test/test-utils/matchers/to-equal-array-unsorted.ts +++ b/test/test-utils/matchers/to-equal-array-unsorted.ts @@ -1,3 +1,4 @@ +import { getOnelineDiffStr } from "#test/test-utils/string-utils"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; /** @@ -28,17 +29,20 @@ export function toEqualArrayUnsorted( } // Create shallow copies of the arrays in case we have - const gotSorted = received.slice().sort(); - const wantSorted = expected.slice().sort(); - const pass = this.equals(gotSorted, wantSorted, [...this.customTesters, this.utils.iterableEquality]); + const actualSorted = received.slice().sort(); + const expectedSorted = expected.slice().sort(); + const pass = this.equals(actualSorted, expectedSorted, [...this.customTesters, this.utils.iterableEquality]); + + const actualStr = getOnelineDiffStr.call(this, actualSorted); + const expectedStr = getOnelineDiffStr.call(this, expectedSorted); return { pass, message: () => pass - ? `Expected ${this.utils.stringify(received)} to NOT exactly equal ${this.utils.stringify(expected)} without order!` - : `Expected ${this.utils.stringify(received)} to exactly equal ${this.utils.stringify(expected)} without order!`, - expected: wantSorted, - actual: gotSorted, + ? `Expected ${actualStr} to NOT exactly equal ${expectedStr} without order, but it did!` + : `Expected ${actualStr} to exactly equal ${expectedStr} without order, but it didn't!`, + expected: expectedSorted, + actual: actualSorted, }; } diff --git a/test/test-utils/matchers/to-have-battler-tag.ts b/test/test-utils/matchers/to-have-battler-tag.ts index 8ff82281d59..af405d7da39 100644 --- a/test/test-utils/matchers/to-have-battler-tag.ts +++ b/test/test-utils/matchers/to-have-battler-tag.ts @@ -29,14 +29,14 @@ export function toHaveBattlerTag( const pass = !!received.getTag(expectedBattlerTagType); const pkmName = getPokemonNameWithAffix(received); // "BattlerTagType.SEEDED (=1)" - const expectedTagStr = getEnumStr(BattlerTagType, expectedBattlerTagType); + const expectedTagStr = getEnumStr(BattlerTagType, expectedBattlerTagType, { prefix: "BattlerTagType." }); return { pass, message: () => pass - ? `Expected ${pkmName} to NOT have BattlerTagType.${expectedTagStr}, but it did!` - : `Expected ${pkmName} to have BattlerTagType.${expectedTagStr}, but it didn't!`, + ? `Expected ${pkmName} to NOT have ${expectedTagStr}, but it did!` + : `Expected ${pkmName} to have ${expectedTagStr}, but it didn't!`, expected: expectedBattlerTagType, actual: received.summonData.tags.map(t => t.tagType), }; diff --git a/test/test-utils/matchers/to-have-stat-stage.ts b/test/test-utils/matchers/to-have-stat-stage.ts index 10e05aed903..b217f744a65 100644 --- a/test/test-utils/matchers/to-have-stat-stage.ts +++ b/test/test-utils/matchers/to-have-stat-stage.ts @@ -43,7 +43,7 @@ export function toHaveStatStage( pass ? `Expected ${pkmName}'s ${statName} stat stage to NOT be ${expectedStage}, but it was!` : `Expected ${pkmName}'s ${statName} stat stage to be ${expectedStage}, but got ${actualStage} instead!`, - actual: actualStage, expected: expectedStage, + actual: actualStage, }; } diff --git a/test/test-utils/matchers/to-have-status-effect.ts b/test/test-utils/matchers/to-have-status-effect.ts index 352d2f736d1..662dbf85aa6 100644 --- a/test/test-utils/matchers/to-have-status-effect.ts +++ b/test/test-utils/matchers/to-have-status-effect.ts @@ -4,7 +4,7 @@ import type { Pokemon } from "#field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; import { StatusEffect } from "#enums/status-effect"; -import { getEnumStr } from "#test/test-utils/string-utils"; +import { getEnumStr, getOnelineDiffStr } from "#test/test-utils/string-utils"; import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; @@ -33,14 +33,20 @@ export function toHaveStatusEffect( } const pkmName = getPokemonNameWithAffix(received); + const actualEffect = received.status?.effect ?? StatusEffect.NONE; - // Check exclusively effect equality - if (typeof expectedStatus === "number" || received.status?.effect !== expectedStatus.effect) { - const actualEffect = received.status?.effect ?? StatusEffect.NONE; + // Check exclusively effect equality first, coercing non-matching status effects to numbers. + if (actualEffect !== (expectedStatus as Exclude)?.effect) { + // This is actually 100% safe as `expectedStatus?.effect` will evaluate to `undefined` if a StatusEffect was passed, + // which will never match actualEffect by definition + expectedStatus = (expectedStatus as Exclude).effect; + } + + if (typeof expectedStatus === "number") { const pass = this.equals(actualEffect, expectedStatus, [...this.customTesters, this.utils.iterableEquality]); const actualStr = getEnumStr(StatusEffect, actualEffect, { prefix: "StatusEffect." }); - const expectedStr = getEnumStr(StatusEffect, actualEffect, { prefix: "StatusEffect." }); + const expectedStr = getEnumStr(StatusEffect, expectedStatus, { prefix: "StatusEffect." }); return { pass, @@ -53,7 +59,7 @@ export function toHaveStatusEffect( }; } - // Check for equality of all fields (for toxic turn count) + // Check for equality of all fields (for toxic turn count/etc) const actualStatus = received.status; const pass = this.equals(received, expectedStatus, [ ...this.customTesters, @@ -61,12 +67,15 @@ export function toHaveStatusEffect( this.utils.iterableEquality, ]); + const expectedStr = getOnelineDiffStr.call(this, expectedStatus); + const actualStr = getOnelineDiffStr.call(this, actualStatus); + return { pass, message: () => pass - ? `Expected ${pkmName}'s status to NOT match ${this.utils.stringify(expectedStatus)}, but it did!` - : `Expected ${pkmName}'s status to match ${this.utils.stringify(expectedStatus)}, but got ${this.utils.stringify(actualStatus)} instead!`, + ? `Expected ${pkmName}'s status to NOT match ${expectedStr}, but it did!` + : `Expected ${pkmName}'s status to match ${expectedStr}, but got ${actualStr} instead!`, expected: expectedStatus, actual: actualStatus, }; diff --git a/test/test-utils/matchers/to-have-terrain.ts b/test/test-utils/matchers/to-have-terrain.ts index 3c81a123ef0..5e2c9e25c2f 100644 --- a/test/test-utils/matchers/to-have-terrain.ts +++ b/test/test-utils/matchers/to-have-terrain.ts @@ -39,8 +39,8 @@ export function toHaveTerrain( pass ? `Expected Arena to NOT have ${expectedStr} active, but it did!` : `Expected Arena to have ${expectedStr} active, but got ${actualStr} instead!`, - actual, expected: expectedTerrainType, + actual, }; } @@ -53,5 +53,6 @@ function toTerrainStr(terrainType: TerrainType) { if (terrainType === TerrainType.NONE) { return "no terrain"; } + // "Electric Terrain (=2)" return getEnumStr(TerrainType, terrainType, { casing: "Title", suffix: " Terrain" }); } diff --git a/test/test-utils/matchers/to-have-types.ts b/test/test-utils/matchers/to-have-types.ts index 0c6006dbaeb..3f16f740583 100644 --- a/test/test-utils/matchers/to-have-types.ts +++ b/test/test-utils/matchers/to-have-types.ts @@ -55,7 +55,7 @@ export function toHaveTypes( pass ? `Expected ${pkmName} to NOT have types ${expectedStr}, but it did!` : `Expected ${pkmName} to have types ${expectedStr}, but got ${actualStr} instead!`, - actual: actualTypes, expected: expectedTypes, + actual: actualTypes, }; } diff --git a/test/test-utils/matchers/to-have-used-move.ts b/test/test-utils/matchers/to-have-used-move.ts index a1fb56c9865..14a60653fc3 100644 --- a/test/test-utils/matchers/to-have-used-move.ts +++ b/test/test-utils/matchers/to-have-used-move.ts @@ -2,7 +2,7 @@ import { getPokemonNameWithAffix } from "#app/messages"; import type { MoveId } from "#enums/move-id"; // biome-ignore lint/correctness/noUnusedImports: TSDocs import type { Pokemon } from "#field/pokemon"; -import { getOrdinal } from "#test/test-utils/string-utils"; +import { getOnelineDiffStr, getOrdinal } from "#test/test-utils/string-utils"; import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils"; import type { TurnMove } from "#types/turn-move"; import type { AtLeastOne } from "#types/type-helpers"; @@ -54,12 +54,14 @@ export function toHaveUsedMove( this.utils.iterableEquality, ]); + const expectedStr = getOnelineDiffStr.call(this, expectedResult); return { pass, message: () => pass - ? `Expected ${pkmName}'s ${moveIndexStr} to NOT match ${this.utils.stringify(expectedResult)}, but it did!` - : `Expected ${pkmName}'s ${moveIndexStr} to match ${this.utils.stringify(expectedResult)}, but got ${this.utils.stringify(move)} instead!`, + ? `Expected ${pkmName}'s ${moveIndexStr} to NOT match ${expectedStr}, but it did!` + : // Replace newlines with spaces to preserve one-line ness + `Expected ${pkmName}'s ${moveIndexStr} to match ${expectedStr}, but it didn't!`, expected: expectedResult, actual: move, }; diff --git a/test/test-utils/matchers/to-have-weather.ts b/test/test-utils/matchers/to-have-weather.ts index 8154efd234e..2352e84862f 100644 --- a/test/test-utils/matchers/to-have-weather.ts +++ b/test/test-utils/matchers/to-have-weather.ts @@ -39,8 +39,8 @@ export function toHaveWeather( pass ? `Expected Arena to NOT have ${expectedStr} weather active, but it did!` : `Expected Arena to have ${expectedStr} weather active, but got ${actualStr} instead!`, - actual, expected: expectedWeatherType, + actual, }; } diff --git a/test/test-utils/string-utils.ts b/test/test-utils/string-utils.ts index 827461d2491..ee92f146c4c 100644 --- a/test/test-utils/string-utils.ts +++ b/test/test-utils/string-utils.ts @@ -2,6 +2,7 @@ import { getStatKey, type Stat } from "#enums/stat"; import type { EnumOrObject, EnumValues, NormalEnum, TSNumericEnum } from "#types/enum-types"; import { enumValueToKey } from "#utils/enums"; import { toTitleCase } from "#utils/strings"; +import type { MatcherState } from "@vitest/expect"; import i18next from "i18next"; type Casing = "Preserve" | "Title"; @@ -23,12 +24,12 @@ interface getEnumStrOptions { } /** - * Helper function to return the name of an enum member or const object value, alongside its corresponding value. + * Return the name of an enum member or const object value, alongside its corresponding value. * @param obj - The {@linkcode EnumOrObject} to source reverse mappings from * @param enums - One of {@linkcode obj}'s values * @param casing - A string denoting the casing method to use; default `Preserve` - * @param prefix - An optional string to be prepended to the enum's string representation. - * @param suffix - An optional string to be appended to the enum's string representation. + * @param prefix - An optional string to be prepended to the enum's string representation + * @param suffix - An optional string to be appended to the enum's string representation * @returns The stringified representation of `val` as dictated by the options. * @example * ```ts @@ -37,8 +38,8 @@ interface getEnumStrOptions { * TWO: 2, * THREE: 3, * } - * console.log(getEnumStr(fakeEnum, fakeEnum.ONE)); // Output: "ONE (=1)" - * console.log(getEnumStr(fakeEnum, fakeEnum.TWO, {case: "Title", prefix: "fakeEnum."})); // Output: "fakeEnum.Two (=2)" + * getEnumStr(fakeEnum, fakeEnum.ONE); // Output: "ONE (=1)" + * getEnumStr(fakeEnum, fakeEnum.TWO, {casing: "Title", prefix: "fakeEnum.", suffix: "!!!"}); // Output: "fakeEnum.TWO!!! (=2)" * ``` */ export function getEnumStr( @@ -73,7 +74,7 @@ export function getEnumStr( * Convert an array of enums or `const object`s into a readable string version. * @param obj - The {@linkcode EnumOrObject} to source reverse mappings from * @param enums - An array of {@linkcode obj}'s values - * @returns The stringified representation of `enums` + * @returns The stringified representation of `enums`. * @example * ```ts * enum fakeEnum { @@ -90,19 +91,49 @@ export function stringifyEnumArray(obj: E, enums: E[keyo } const vals = enums.slice(); + /** An array of string names */ let names: string[]; if (obj[enums[0]] !== undefined) { - // Reverse mapping exists - `obj` is a `TSNumericEnum` and its reverse mapped counterparts + // Reverse mapping exists - `obj` is a `TSNumericEnum` and its reverse mapped counterparts are strings names = enums.map(e => (obj as TSNumericEnum)[e] as string); } else { - // No reverse mapping exists means `obj` is a `NormalEnum` - names = enums.map(e => enumValueToKey(obj as NormalEnum, e) as string); + // No reverse mapping exists means `obj` is a `NormalEnum`. + // NB: This (while ugly) should be more ergonomic than doing a repeated lookup for large `const object`s + // as the `enums` array should be significantly shorter than the corresponding enum type. + names = []; + for (const [k, v] of Object.entries(obj as NormalEnum)) { + if (names.length === enums.length) { + // No more names to get + break; + } + // Find all matches for the given enum, assigning their keys to the names array + findIndices(enums, v).forEach(matchIndex => { + names[matchIndex] = k; + }); + } } - return `[${names.join(", ")}] (=[${vals.join(", ")}])`; } +/** + * Return the indices of all occurrences of a value in an array. + * @param arr - The array to search + * @param searchElement - The value to locate in the array + * @param fromIndex - The array index at which to begin the search. If fromIndex is omitted, the + * search starts at index 0 + */ +function findIndices(arr: T[], searchElement: T, fromIndex = 0): number[] { + const indices: number[] = []; + const arrSliced = arr.slice(fromIndex); + for (const [index, value] of arrSliced.entries()) { + if (value === searchElement) { + indices.push(index); + } + } + return indices; +} + /** * Convert a number into an English ordinal * @param num - The number to convert into an ordinal @@ -137,3 +168,15 @@ export function getOrdinal(num: number): string { export function getStatName(s: Stat): string { return i18next.t(getStatKey(s)); } + +/** + * Convert an object into a oneline diff to be shown in an error message. + * @param obj - The object to return the oneline diff of + * @returns The updated diff + */ +export function getOnelineDiffStr(this: MatcherState, obj: unknown): string { + return this.utils + .stringify(obj, undefined, { maxLength: 35, indent: 0, printBasicPrototype: false }) + .replace(/\n/g, " ") // Replace newlines with spaces + .replace(/,(\s*)}$/g, "$1}"); +}