Repository: dubzzz/fast-check
Pull request #2015 (2021-06-21 18:08:00)

✨ Forward the custom toString methods of the underlying commands
<!-- Context of the PR: short description and potentially linked issues -->

<!-- ...a few words to describe the content of this PR...               -->
<!-- ... -->

<!-- Type of PR: [ ] unchecked / [ ] checked -->
**_Category:_**

- [x] ✨ Introduce new features
- [ ] 📝 Add or update documentation
- [ ] ✅ Add or update tests
- [ ] 🐛 Fix a bug
- [ ] 🏷️ Add or update types
- [ ] ⚡️ Improve performance
- [ ] _Other(s):_ ...
<!-- Don't forget to add the gitmoji icon in the name of the PR -->
<!-- See: https://gitmoji.dev/                                  -->

<!-- Fixing bugs, adding features... may impact existing ones           -->
<!-- in order to track potential issues that could be related to your PR -->
<!-- please check the impacts and describe more precisely what to expect -->

**_Potential impacts:_**

<!-- Generated values: Can your change impact any of the existing generators in terms of generated values, if so which ones? when? -->
<!-- Shrink values:    Can your change impact any of the existing generators in terms of shrink values, if so which ones? when? -->
<!-- Performance:      Can it require some typings changes on user side? Please give more details -->
<!-- Typings:          Is there a potential performance impact? In which cases? -->
- [ ] Generated values
- [ ] Shrink values
- [ ] Performance
- [ ] Typings
- [ ] _Other(s):_ ...


codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/dubzzz/fast-check/pr/2015/builds/142053) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 4282a3fc264d240461e6981eea6cce8faf3d108c:

|Sandbox| Source |
|--|--|
|[Vanilla](https://codesandbox.io/s/vanilla-cb21b)| Configuration |
|[fast-check-examples](https://codesandbox.io/s/fast-check-examples-icm75)| Configuration |

codecov[bot]: # [Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/2015?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) Report
> Merging [#2015](https://codecov.io/gh/dubzzz/fast-check/pull/2015?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) (4282a3f) into [main](https://codecov.io/gh/dubzzz/fast-check/commit/dc2a29c0eda51cf4b392257dbc1104df78b125e9?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) (dc2a29c) will **increase** coverage by `0.01%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/dubzzz/fast-check/pull/2015/graphs/tree.svg?width=650&height=150&src=pr&token=fRi2fJN730&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN)](https://codecov.io/gh/dubzzz/fast-check/pull/2015?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN)

```diff
@@            Coverage Diff             @@
##             main    #2015      +/-   ##
==========================================
+ Coverage   96.67%   96.69%   +0.01%     
==========================================
  Files         166      166              
  Lines        4246     4266      +20     
  Branches      786      795       +9     
==========================================
+ Hits         4105     4125      +20     
  Misses        137      137              
  Partials        4        4              
```

| Flag | Coverage Δ | |
|---|---|---|
| unit-tests | `96.69% <100.00%> (+0.01%)` | :arrow_up: |
| unit-tests-12.x-Linux | `?` | |
| unit-tests-14.x-Linux | `?` | |
| unit-tests-16.x-Linux | `96.69% <100.00%> (+0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/dubzzz/fast-check/pull/2015?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) | Coverage Δ | |
|---|---|---|
| [src/check/model/commands/CommandWrapper.ts](https://codecov.io/gh/dubzzz/fast-check/pull/2015/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2NoZWNrL21vZGVsL2NvbW1hbmRzL0NvbW1hbmRXcmFwcGVyLnRz) | `100.00% <100.00%> (ø)` | |
| [src/utils/stringify.ts](https://codecov.io/gh/dubzzz/fast-check/pull/2015/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL3V0aWxzL3N0cmluZ2lmeS50cw==) | `98.96% <100.00%> (+0.06%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/2015?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/2015?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN). Last update [dc2a29c...4282a3f](https://codecov.io/gh/dubzzz/fast-check/pull/2015?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN).


Patch:
diff --git a/src/check/model/commands/CommandWrapper.ts b/src/check/model/commands/CommandWrapper.ts
index 1e98e4a3..4a950a68 100644
--- a/src/check/model/commands/CommandWrapper.ts
+++ b/src/check/model/commands/CommandWrapper.ts
@@ -1,3 +1,9 @@
+import {
+  asyncToStringMethod,
+  hasAsyncToStringMethod,
+  hasToStringMethod,
+  toStringMethod,
+} from '../../../utils/stringify';
 import { cloneMethod, hasCloneMethod } from '../../symbols';
 import { ICommand } from '../command/ICommand';
 
@@ -9,8 +15,24 @@ import { ICommand } from '../command/ICommand';
 export class CommandWrapper<Model extends object, Real, RunResult, CheckAsync extends boolean>
   implements ICommand<Model, Real, RunResult, CheckAsync>
 {
+  [toStringMethod]?: () => string;
+  [asyncToStringMethod]?: () => Promise<string>;
+
   hasRan = false;
-  constructor(readonly cmd: ICommand<Model, Real, RunResult, CheckAsync>) {}
+  constructor(readonly cmd: ICommand<Model, Real, RunResult, CheckAsync>) {
+    if (hasToStringMethod(cmd)) {
+      const method = cmd[toStringMethod];
+      this[toStringMethod] = function toStringMethod(): string {
+        return method.call(cmd);
+      };
+    }
+    if (hasAsyncToStringMethod(cmd)) {
+      const method = cmd[asyncToStringMethod];
+      this[asyncToStringMethod] = function asyncToStringMethod(): Promise<string> {
+        return method.call(cmd);
+      };
+    }
+  }
   check(m: Readonly<Model>): CheckAsync extends false ? boolean : Promise<boolean> {
     return this.cmd.check(m);
   }
diff --git a/src/utils/stringify.ts b/src/utils/stringify.ts
index 9efff00f..2253b06c 100644
--- a/src/utils/stringify.ts
+++ b/src/utils/stringify.ts
@@ -3,13 +3,34 @@
  * @internal
  */
 export const toStringMethod = Symbol('fast-check/toStringMethod');
+/** @internal */
+export type WithToStringMethod = { [toStringMethod]: () => string };
+/** @internal */
+export function hasToStringMethod<T>(instance: T): instance is T & WithToStringMethod {
+  return (
+    instance !== null &&
+    (typeof instance === 'object' || typeof instance === 'function') &&
+    toStringMethod in instance &&
+    typeof (instance as any)[toStringMethod] === 'function'
+  );
+}
+
 /**
  * Ability to override the default output of stringified for a given value
  * @internal
  */
 export const asyncToStringMethod = Symbol('fast-check/asyncToStringMethod');
 /** @internal */
-type WithAsyncToStringMethod = { [asyncToStringMethod]: () => Promise<string> };
+export type WithAsyncToStringMethod = { [asyncToStringMethod]: () => Promise<string> };
+/** @internal */
+export function hasAsyncToStringMethod<T>(instance: T): instance is T & WithAsyncToStringMethod {
+  return (
+    instance !== null &&
+    (typeof instance === 'object' || typeof instance === 'function') &&
+    asyncToStringMethod in instance &&
+    typeof (instance as any)[asyncToStringMethod] === 'function'
+  );
+}
 
 /** @internal */
 const findSymbolNameRegex = /^Symbol\((.*)\)$/;
@@ -70,23 +91,23 @@ export function stringifyInternal<Ts>(
       return '[cyclic]';
     }
   }
-  if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
-    // user defined custom async serialization function, we use it first
-    if (asyncToStringMethod in value) {
-      const content = getAsyncContent(value as any as WithAsyncToStringMethod);
-      if (content.state === 'fulfilled') {
-        return content.value as string;
-      }
+
+  if (hasAsyncToStringMethod(value)) {
+    // if user defined custom async serialization function, we use it first
+    const content = getAsyncContent(value);
+    if (content.state === 'fulfilled') {
+      return content.value as string;
     }
-    // user defined custom sync serialization function
-    if (toStringMethod in value) {
-      try {
-        return ((value as any)[toStringMethod] as () => string)();
-      } catch (err) {
-        // fallback to defaults...
-      }
+  }
+  if (hasToStringMethod(value)) {
+    // if user defined custom sync serialization function, we use it before next ones
+    try {
+      return value[toStringMethod]();
+    } catch (err) {
+      // fallback to defaults...
     }
   }
+
   switch (Object.prototype.toString.call(value)) {
     case '[object Array]': {
       const arr = value as unknown as unknown[];


Test patch:
diff --git a/test/unit/check/model/commands/CommandWrapper.spec.ts b/test/unit/check/model/commands/CommandWrapper.spec.ts
index 0e9f4041..6fea1bb4 100644
--- a/test/unit/check/model/commands/CommandWrapper.spec.ts
+++ b/test/unit/check/model/commands/CommandWrapper.spec.ts
@@ -2,6 +2,7 @@ import { CommandWrapper } from '../../../../../src/check/model/commands/CommandW
 import { Command } from '../../../../../src/check/model/command/Command';
 import { AsyncCommand } from '../../../../../src/check/model/command/AsyncCommand';
 import { cloneMethod } from '../../../../../src/check/symbols';
+import { asyncToStringMethod, toStringMethod } from '../../../../../src/utils/stringify';
 
 type Model = Record<string, unknown>;
 type Real = unknown;
@@ -116,4 +117,50 @@ describe('CommandWrapper', () => {
     expect(cloned).not.toBe(wrapper);
     expect(cloned.cmd).toBe(wrapper.cmd);
   });
+  it('Should not define [toStringMethod] if underlying command does not', async () => {
+    const cmd = new (class implements Command<Model, Real> {
+      check = jest.fn();
+      run = jest.fn();
+      toString = jest.fn();
+    })();
+
+    const wrapper = new CommandWrapper(cmd);
+    expect(toStringMethod in wrapper).toBe(false);
+  });
+  it('Should define [toStringMethod] if underlying command does', async () => {
+    const cmd = new (class implements Command<Model, Real> {
+      check = jest.fn();
+      run = jest.fn();
+      toString = jest.fn();
+      [toStringMethod] = () => 'hello';
+    })();
+
+    const wrapper = new CommandWrapper(cmd);
+    expect(toStringMethod in wrapper).toBe(true);
+    expect(asyncToStringMethod in wrapper).toBe(false);
+    expect((wrapper as any)[toStringMethod]()).toBe('hello');
+  });
+  it('Should not define [asyncToStringMethod] if underlying command does not', async () => {
+    const cmd = new (class implements Command<Model, Real> {
+      check = jest.fn();
+      run = jest.fn();
+      toString = jest.fn();
+    })();
+
+    const wrapper = new CommandWrapper(cmd);
+    expect(toStringMethod in wrapper).toBe(false);
+  });
+  it('Should define [asyncToStringMethod] if underlying command does', async () => {
+    const cmd = new (class implements Command<Model, Real> {
+      check = jest.fn();
+      run = jest.fn();
+      toString = jest.fn();
+      [asyncToStringMethod] = async () => 'world';
+    })();
+
+    const wrapper = new CommandWrapper(cmd);
+    expect(asyncToStringMethod in wrapper).toBe(true);
+    expect(toStringMethod in wrapper).toBe(false);
+    expect(await (wrapper as any)[asyncToStringMethod]()).toBe('world');
+  });
 });