Repository: Despair-Games/poketernity
Pull request #1063: [Test] Fix test matchers' behaviour when receiving wrong object type
Author: login: PigeonBar
Labels: name: Tests
description: Automated tests related
Created: 2025-04-25T01:05:38Z | Merged: 2025-04-26T22:06:17Z

<!-- (Once you have read these comments, you are free to remove them) -->
<!-- Feel free to look at other PRs for examples -->
<!--
Make sure the title includes categorization (choose the one that best fits):
-       [Bug]: If the PR is primarily a bug fix
-      [Move]: If a move has new or changed functionality
-   [Ability]: If an ability has new or changed functionality
-      [Item]: For new or modified items
-   [Mystery]: For new or modified Mystery Encounters
-      [Test]: If the PR is primarily adding or modifying tests
-     [UI/UX]: If the PR is changing UI/UX elements
-     [Audio]: If the PR is adding or changing music/sfx
-    [Sprite]: If the PR is adding or changing sprites
-   [Balance]: If the PR is related to game balance
- [Challenge]: If the PR is adding or modifying challenges
-  [Refactor]: If the PR is primarily rewriting existing code
-      [Docs]: If the PR is just adding or modifying documentation (such as tsdocs/code comments)
-    [GitHub]: For changes to GitHub workflows/templates/etc
-      [Misc]: If no other category fits the PR
-->
<!--
Make sure that this PR is not overlapping with someone else's work
Please try to keep the PR self-contained (and small)
-->

## What are the changes the developer will see?

<!-- Summarize what are the changes from a user perspective on the application -->

When using the newly introduced matchers (see `matchers.setup.ts`), if `expect(object).not` is used and the object is of the wrong type, the test now correctly fails.

## Why am I making these changes?

<!--
Explain why you decided to introduce these changes
Does it come from an issue or another PR? Please link it
Explain why you believe this can enhance user experience
-->
<!--
If there are existing GitHub issues related to the PR that would be fixed,
you can add "Fixes #[issue number]" (ie: "Fixes #1234") to link an issue to your PR
so that it will automatically be closed when the PR is merged.
-->

I just realized that since `expect().not` inverts the `pass` result, returning `pass: false` when the object is of the wrong type causes the test to pass when it should not.

## What are the changes from a developer perspective?

<!--
Explicitly state what are the changes introduced by the PR
You can make use of a comparison between what was the state before and after your PR changes
Ex: What files have been changed? What classes/functions/variables/etc have been added or changed?
-->

According to the [vitest docs](https://vitest.dev/guide/extending-matchers), `this.isNot` can be used to tell whether `expect().not` was used. So, if the test needs to fail due to the received object being the wrong type, it now returns `pass: false` if `this.isNot === false` and returns `pass: true` if `this.isNot === true`.

## Screenshots/Videos

<!--
If your changes are changing anything on the user experience, please provide visual proofs of it
Please take screenshots/videos before and after your changes, to show what is brought by this PR
-->

## How to test the changes?

<!--
How can a reviewer test your changes once they check out on your branch?
Did you make use of the `src/overrides.ts` file?
Did you introduce any automated tests?
Do the reviewers need to do something special in order to test your changes?
-->

`expect(undefined).not.toHaveFullHp(); // Test should fail`

## Checklist

- [x] Otherwise: **I'm using `beta` as my base branch**
- [x] There is no overlap with another PR?
- [x] The PR is self-contained and cannot be split into smaller PRs?
- [x] Have I provided a clear explanation of the changes?
- [x] Have I tested the changes manually?
- [x] Are all unit tests still passing? (`npm run test:silent`)


Diff:
diff --git a/test/matchers.setup.ts b/test/matchers.setup.ts
index 42b60f1a81..54877347a1 100644
--- a/test/matchers.setup.ts
+++ b/test/matchers.setup.ts
@@ -1,10 +1,10 @@
 import { toHaveEffectiveStatMatcher } from "#test/matchers/to-have-effective-stat-matcher";
-import { toHaveFullHpMatcher } from "#test/matchers/to-have-full-hp";
+import { toHaveFullHpMatcher } from "#test/matchers/to-have-full-hp-matcher";
 import { toHaveMoveResultMatcher } from "#test/matchers/to-have-move-result-matcher";
 import { toHaveStatMatcher } from "#test/matchers/to-have-stat-matcher";
 import { toHaveTakenDamageMatcher } from "#test/matchers/to-have-taken-damage-matcher";
 import { toHaveUsedMoveMatcher } from "#test/matchers/to-have-used-move-matcher";
-import { toHaveWeatherMatcher } from "#test/matchers/to-have-weather";
+import { toHaveWeatherMatcher } from "#test/matchers/to-have-weather-matcher";
 import { expect } from "vitest";
 
 /**
diff --git a/test/matchers/to-have-effective-stat-matcher.ts b/test/matchers/to-have-effective-stat-matcher.ts
index 0168eba9d0..16005c2527 100644
--- a/test/matchers/to-have-effective-stat-matcher.ts
+++ b/test/matchers/to-have-effective-stat-matcher.ts
@@ -2,7 +2,8 @@ import type { Move } from "#app/data/moves/move";
 import type { Pokemon } from "#app/field/pokemon";
 import { AbilityApplyMode } from "#enums/ability-apply-mode";
 import { Stat, type EffectiveStat } from "#enums/stat";
-import { receivedStr, isPokemonInstance } from "#test/test-utils/testUtils";
+import { isPokemonInstance, receivedStr } from "#test/test-utils/testUtils";
+import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
 
 export interface ToHaveEffectiveStatMatcherOptions {
   /**
@@ -31,14 +32,15 @@ export interface ToHaveEffectiveStatMatcherOptions {
  * @returns Whether the matcher passed
  */
 export function toHaveEffectiveStatMatcher(
+  this: MatcherState,
   received: unknown,
   stat: EffectiveStat,
   expectedValue: number,
   { enemy, move, isCritical = false }: ToHaveEffectiveStatMatcherOptions = {},
-) {
+): SyncExpectationResult {
   if (!isPokemonInstance(received)) {
     return {
-      pass: false,
+      pass: this.isNot,
       message: () => `Expected Pokemon, but got ${receivedStr(received)}!`,
     };
   }
diff --git a/test/matchers/to-have-full-hp.ts b/test/matchers/to-have-full-hp-matcher.ts
similarity index 71%
rename from test/matchers/to-have-full-hp.ts
rename to test/matchers/to-have-full-hp-matcher.ts
index 1f4c20e604..6527a37a54 100644
--- a/test/matchers/to-have-full-hp.ts
+++ b/test/matchers/to-have-full-hp-matcher.ts
@@ -1,15 +1,15 @@
-import { receivedStr, isPokemonInstance } from "#test/test-utils/testUtils";
-import type { SyncExpectationResult } from "@vitest/expect";
+import { isPokemonInstance, receivedStr } from "#test/test-utils/testUtils";
+import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
 
 /**
  * Matcher to check if a Pokemon is full hp.
  * @param received - The object to check. Should be a {@linkcode Pokemon}.
  * @returns Whether the matcher passed
  */
-export function toHaveFullHpMatcher(received: unknown): SyncExpectationResult {
+export function toHaveFullHpMatcher(this: MatcherState, received: unknown): SyncExpectationResult {
   if (!isPokemonInstance(received)) {
     return {
-      pass: false,
+      pass: this.isNot,
       message: () => `Expected Pokemon, but got ${receivedStr(received)}!`,
     };
   }
diff --git a/test/matchers/to-have-move-result-matcher.ts b/test/matchers/to-have-move-result-matcher.ts
index f984a633e3..fa0fe24b8e 100644
--- a/test/matchers/to-have-move-result-matcher.ts
+++ b/test/matchers/to-have-move-result-matcher.ts
@@ -1,6 +1,6 @@
 import { MoveResult } from "#enums/move-result";
-import { receivedStr, isPokemonInstance } from "#test/test-utils/testUtils";
-import type { SyncExpectationResult } from "@vitest/expect";
+import { isPokemonInstance, receivedStr } from "#test/test-utils/testUtils";
+import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
 
 //#region Types
 
@@ -26,13 +26,14 @@ export interface ToHaveMoveResultMatcherOptions {
  * @returns Whether the matcher passed
  */
 export function toHaveMoveResultMatcher(
+  this: MatcherState,
   received: unknown,
   expectedResult: MoveResult,
   { index = 0, moveCount = 1 }: ToHaveMoveResultMatcherOptions = {},
 ): SyncExpectationResult {
   if (!isPokemonInstance(received)) {
     return {
-      pass: false,
+      pass: this.isNot,
       message: () => `Expected Pokemon, but got ${receivedStr(received)}!`,
     };
   }
diff --git a/test/matchers/to-have-stat-matcher.ts b/test/matchers/to-have-stat-matcher.ts
index 37729d03a7..f584d88de1 100644
--- a/test/matchers/to-have-stat-matcher.ts
+++ b/test/matchers/to-have-stat-matcher.ts
@@ -1,7 +1,7 @@
 import type { Pokemon } from "#app/field/pokemon";
 import { Stat, type PermanentStat } from "#enums/stat";
-import { receivedStr, isPokemonInstance } from "#test/test-utils/testUtils";
-import type { SyncExpectationResult } from "@vitest/expect";
+import { isPokemonInstance, receivedStr } from "#test/test-utils/testUtils";
+import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
 
 export interface ToHaveStatMatcherOptions {
   /**
@@ -20,6 +20,7 @@ export interface ToHaveStatMatcherOptions {
  * @returns Whether the matcher passed
  */
 export function toHaveStatMatcher(
+  this: MatcherState,
   received: unknown,
   stat: PermanentStat,
   expectedValue: number,
@@ -27,7 +28,7 @@ export function toHaveStatMatcher(
 ): SyncExpectationResult {
   if (!isPokemonInstance(received)) {
     return {
-      pass: false,
+      pass: this.isNot,
       message: () => `Expected Pokemon, but got ${receivedStr(received)}!`,
     };
   }
diff --git a/test/matchers/to-have-taken-damage-matcher.ts b/test/matchers/to-have-taken-damage-matcher.ts
index c6827f09d4..f346e348ea 100644
--- a/test/matchers/to-have-taken-damage-matcher.ts
+++ b/test/matchers/to-have-taken-damage-matcher.ts
@@ -1,4 +1,5 @@
-import { receivedStr, isPokemonInstance } from "#test/test-utils/testUtils";
+import { isPokemonInstance, receivedStr } from "#test/test-utils/testUtils";
+import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
 
 /**
  * Matcher to check if a Pokemon has taken a specific amount of damage
@@ -6,10 +7,14 @@ import { receivedStr, isPokemonInstance } from "#test/test-utils/testUtils";
  * @param expectedDamageTaken - The expected amount of damage the {@linkcode Pokemon} has taken
  * @returns Whether the matcher passed
  */
-export function toHaveTakenDamageMatcher(received: unknown, expectedDamageTaken: number) {
+export function toHaveTakenDamageMatcher(
+  this: MatcherState,
+  received: unknown,
+  expectedDamageTaken: number,
+): SyncExpectationResult {
   if (!isPokemonInstance(received)) {
     return {
-      pass: false,
+      pass: this.isNot,
       message: () => `Expected Pokemon, but got ${receivedStr(received)}!`,
     };
   }
diff --git a/test/matchers/to-have-used-move-matcher.ts b/test/matchers/to-have-used-move-matcher.ts
index 8ec45e6cb9..ac21980dba 100644
--- a/test/matchers/to-have-used-move-matcher.ts
+++ b/test/matchers/to-have-used-move-matcher.ts
@@ -1,5 +1,7 @@
 import { MoveId } from "#enums/move-id";
-import { receivedStr, isPokemonInstance } from "#test/test-utils/testUtils";
+import { isPokemonInstance, receivedStr } from "#test/test-utils/testUtils";
+import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
+
 //#region Types
 
 export interface ToHaveUsedMoveMatcherOptions {
@@ -24,13 +26,14 @@ export interface ToHaveUsedMoveMatcherOptions {
  * @returns Whether the matcher passed
  */
 export function toHaveUsedMoveMatcher(
+  this: MatcherState,
   received: unknown,
   expectedResult: MoveId,
   { index = 0, moveCount = 1 }: ToHaveUsedMoveMatcherOptions = {},
-) {
+): SyncExpectationResult {
   if (!isPokemonInstance(received)) {
     return {
-      pass: false,
+      pass: this.isNot,
       message: () => `Expected Pokemon, but got ${receivedStr(received)}!`,
     };
   }
diff --git a/test/matchers/to-have-weather.ts b/test/matchers/to-have-weather-matcher.ts
similarity index 81%
rename from test/matchers/to-have-weather.ts
rename to test/matchers/to-have-weather-matcher.ts
index c1bff32241..02bc9deae0 100644
--- a/test/matchers/to-have-weather.ts
+++ b/test/matchers/to-have-weather-matcher.ts
@@ -1,6 +1,7 @@
 import { capitalizeString } from "#app/utils";
 import { WeatherType } from "#enums/weather-type";
 import { isGameManagerInstance, receivedStr } from "#test/test-utils/testUtils";
+import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
 
 /**
  * Matcher to check if the {@linkcode WeatherType} is as expected
@@ -8,17 +9,21 @@ import { isGameManagerInstance, receivedStr } from "#test/test-utils/testUtils";
  * @param expectedWeatherType - The expected {@linkcode WeatherType}
  * @returns Whether the matcher passed
  */
-export function toHaveWeatherMatcher(received: unknown, expectedWeatherType: WeatherType) {
+export function toHaveWeatherMatcher(
+  this: MatcherState,
+  received: unknown,
+  expectedWeatherType: WeatherType,
+): SyncExpectationResult {
   if (!isGameManagerInstance(received)) {
     return {
-      pass: false,
-      message: () => `Expected Pokemon, but got ${receivedStr(received)}!`,
+      pass: this.isNot,
+      message: () => `Expected GameManager, but got ${receivedStr(received)}!`,
     };
   }
 
   if (!received.scene?.arena) {
     return {
-      pass: false,
+      pass: this.isNot,
       message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`,
     };
   }
diff --git a/test/test-utils/testUtils.ts b/test/test-utils/testUtils.ts
index 24e138f92d..d0f4f31479 100644
--- a/test/test-utils/testUtils.ts
+++ b/test/test-utils/testUtils.ts
@@ -64,19 +64,20 @@ export function getAppRootDir() {
  * @returns A human readable string of the received object (type)
  */
 export function receivedStr(received: unknown, expectedType: TypeOfResult = "object"): string {
-  let what = "unknown";
-
   if (received === null) {
-    what = "null";
-  } else if (received === undefined) {
-    what = "undefined";
-  } else if (typeof received !== expectedType) {
-    what = typeof received;
-  } else if (expectedType === "object") {
-    what = received.constructor.name;
+    return "null";
+  }
+  if (received === undefined) {
+    return "undefined";
+  }
+  if (typeof received !== expectedType) {
+    return typeof received;
+  }
+  if (expectedType === "object") {
+    return received.constructor.name;
   }
 
-  return `but got ${what}!`;
+  return "unknown";
 }
 
 /**


Reviews:
user: login: Tempo-anon
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T01:37:22Z
user: login: PigeonBar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T01:45:10Z
user: login: DayKev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T02:34:25Z
user: login: DayKev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T02:35:09Z
user: login: flx-sta
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T03:32:00Z
user: login: Tempo-anon
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T22:06:10Z

Review comments:
diff_hunk: @@ -76,7 +76,7 @@ export function receivedStr(received: unknown, expectedType: TypeOfResult = "obj
     what = received.constructor.name;
   }
 
-  return `but got ${what}!`;
+  return `${what}`;
path: test/test-utils/testUtils.ts
user: login: Tempo-anon
body: Removing the`but got`?

Also not a fan of the `what` name. Maybe something like `receivedObject` or `resultStr`?
created_at: 2025-04-25T01:37:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -76,7 +76,7 @@ export function receivedStr(received: unknown, expectedType: TypeOfResult = "obj
     what = received.constructor.name;
   }
 
-  return `but got ${what}!`;
+  return `${what}`;
path: test/test-utils/testUtils.ts
user: login: PigeonBar
body: The `but got` is removed here because it already appears in all of the matcher files.
created_at: 2025-04-25T01:45:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,19 +64,17 @@ export function getAppRootDir() {
  * @returns A human readable string of the received object (type)
  */
 export function receivedStr(received: unknown, expectedType: TypeOfResult = "object"): string {
-  let what = "unknown";
-
   if (received === null) {
-    what = "null";
+    return "null";
   } else if (received === undefined) {
-    what = "undefined";
+    return "undefined";
   } else if (typeof received !== expectedType) {
-    what = typeof received;
+    return typeof received;
   } else if (expectedType === "object") {
-    what = received.constructor.name;
+    return received.constructor.name;
+  } else {
+    return "unknown";
   }
path: test/test-utils/testUtils.ts
user: login: DayKev
body: ```suggestion
  }

  return "unknown";
```
created_at: 2025-04-25T02:34:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,19 +64,17 @@ export function getAppRootDir() {
  * @returns A human readable string of the received object (type)
  */
 export function receivedStr(received: unknown, expectedType: TypeOfResult = "object"): string {
-  let what = "unknown";
-
   if (received === null) {
-    what = "null";
+    return "null";
   } else if (received === undefined) {
path: test/test-utils/testUtils.ts
user: login: DayKev
body: ```suggestion
  }
  if (received === undefined) {
```
created_at: 2025-04-25T02:34:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,19 +64,17 @@ export function getAppRootDir() {
  * @returns A human readable string of the received object (type)
  */
 export function receivedStr(received: unknown, expectedType: TypeOfResult = "object"): string {
-  let what = "unknown";
-
   if (received === null) {
-    what = "null";
+    return "null";
   } else if (received === undefined) {
-    what = "undefined";
+    return "undefined";
   } else if (typeof received !== expectedType) {
path: test/test-utils/testUtils.ts
user: login: DayKev
body: ```suggestion
  }
  if (typeof received !== expectedType) {
```
created_at: 2025-04-25T02:34:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,19 +64,17 @@ export function getAppRootDir() {
  * @returns A human readable string of the received object (type)
  */
 export function receivedStr(received: unknown, expectedType: TypeOfResult = "object"): string {
-  let what = "unknown";
-
   if (received === null) {
-    what = "null";
+    return "null";
   } else if (received === undefined) {
-    what = "undefined";
+    return "undefined";
   } else if (typeof received !== expectedType) {
-    what = typeof received;
+    return typeof received;
   } else if (expectedType === "object") {
path: test/test-utils/testUtils.ts
user: login: DayKev
body: ```suggestion
  }
  if (expectedType === "object") {
```
created_at: 2025-04-25T02:35:04Z
author_association: CONTRIBUTOR

PR comments:
user: login: flx-sta
created_at: 2025-04-25T02:53:15Z
body: @PigeonBar would you mind adding the missing response types to
The matchers where I forgot to add it ?
author_association: CONTRIBUTOR
user: login: flx-sta
created_at: 2025-04-25T05:07:41Z
body: EDIT: Provided those changes as addendum:
#1067 

-----

<details><summary>[Original Comments]</summary>
<p>

> @PigeonBar would you mind adding the missing response types to
> The matchers where I forgot to add it ?

> We should also add:
> ```ts
> import type { MatcherState, [...] } from "@vitest/expect";
> 
> export function <MATCHER_NAME>(
>   this: MatcherState,
> ```
>
> To every matcher.
> It makes sure to have proper type hints for `this`, but the `this` doesn't have to be provided as extra param when calling. That's just how `expect.extend()` works. Just gotta make sure to not include the `this` param in the vitest.d.ts definition 

</p>
</details> 
author_association: CONTRIBUTOR