Repository: dubzzz/fast-check (ts)

Issue:
Unnecessary repetitions
## 🐛 Bug Report

Fast-check shouldn't run more tests that are necessary to check all cases 

## To Reproduce

Steps to reproduce:
```
const arb = fc.record({
    left: fc.boolean(),
    right: fc.boolean(),
});

it('should run test only 4 times', () => {
    fc.assert(fc.property(arb, (data) => {
        console.log(data);
    }));
});
```
If I run this test, it will log data 100 times (default count)

## Expected behavior

Fast-check shouldn't repeat cases and run only 4

## Your environment

| Packages / Softwares  | Version(s) |
| --------------------- | ---------- |
| fast-check            |  2.13.0          |
| node                  |  14.16.0          |
| TypeScript           |  4.2.2          |

Pull request:
Add a way to ignore already covered cases in property check
<!-- Why is this PR for? -->
<!-- Describe the reason why you opened this PR or give a link towards the associated issue. -->
For some tests possible cases count is very small, and we don't need to repeat them again and again. This feature will allow detecting repetitions and skip them. Resolves #1576

## In a nutshell

✔️ New feature
❌ Fix an issue
❌ Documentation improvement
❌ Other: *please explain*

(✔️: yes, ❌: no)

## Potential impacts

Performance impact due to a decrease of the property check runs.

Patch:
diff --git a/documentation/Runners.md b/documentation/Runners.md
index b08d860c..a466f6e4 100644
--- a/documentation/Runners.md
+++ b/documentation/Runners.md
@@ -110,6 +110,8 @@ export interface Parameters<T = void> {
     interruptAfterTimeLimit?: number; // optional, interrupt test execution after a given time limit
                         // in milliseconds (relies on Date.now): disabled by default
     markInterruptAsFailure?: boolean; // optional, mark interrupted runs as failure: disabled by default
+    ignoreEqualValues?: boolean; // optional, do not repeat runs with already covered cases: disabled by default
+    					// This is useful when arbitrary has a limited number of variants
     reporter?: (runDetails: RunDetails<T>) => void; // optional, custom reporter replacing the default one
                         // reporter is responsible for throwing in case of failure, as an example default one throws
                         // whenever `runDetails.failed` is true but it is up to you
@@ -212,4 +214,4 @@ In order to define the default parameters that will be used by runners you can u
 - `fc.resetConfigureGlobal()`: reset the default parameters to be used by runners
 - `fc.readConfigureGlobal()`: output the default parameters to be used by runners
 
-See [Tips / Setup global settings](./Tips.md#setup-global-settings) for more details.
+See [Tips / Setup global settings](./Tips.md#setup-global-settings) for more details.
diff --git a/src/check/property/IgnoreEqualValuesProperty.ts b/src/check/property/IgnoreEqualValuesProperty.ts
new file mode 100644
index 00000000..b84b8aff
--- /dev/null
+++ b/src/check/property/IgnoreEqualValuesProperty.ts
@@ -0,0 +1,22 @@
+import { IRawProperty } from './IRawProperty';
+import { Random } from '../../random/generator/Random';
+import { Shrinkable } from '../arbitrary/definition/Shrinkable';
+import { stringify } from '../../utils/stringify';
+
+export class IgnoreEqualValuesProperty<Ts, IsAsync extends boolean> implements IRawProperty<Ts, IsAsync> {
+  private coveredCases: Map<string, ReturnType<IRawProperty<Ts, IsAsync>['run']>> = new Map();
+
+  constructor(readonly property: IRawProperty<Ts, IsAsync>) {}
+
+  isAsync = (): IsAsync => this.property.isAsync();
+  generate = (mrng: Random, runId?: number): Shrinkable<Ts> => this.property.generate(mrng, runId);
+  run = (v: Ts): ReturnType<IRawProperty<Ts, IsAsync>['run']> => {
+    const stringifiedValue = stringify(v);
+    if (this.coveredCases.has(stringifiedValue)) {
+      return this.coveredCases.get(stringifiedValue) as ReturnType<IRawProperty<Ts, IsAsync>['run']>;
+    }
+    const out = this.property.run(v);
+    this.coveredCases.set(stringifiedValue, out);
+    return out;
+  };
+}
diff --git a/src/check/runner/DecorateProperty.ts b/src/check/runner/DecorateProperty.ts
index 5e174844..8648e7ad 100644
--- a/src/check/runner/DecorateProperty.ts
+++ b/src/check/runner/DecorateProperty.ts
@@ -3,11 +3,12 @@ import { SkipAfterProperty } from '../property/SkipAfterProperty';
 import { TimeoutProperty } from '../property/TimeoutProperty';
 import { UnbiasedProperty } from '../property/UnbiasedProperty';
 import { QualifiedParameters } from './configuration/QualifiedParameters';
+import { IgnoreEqualValuesProperty } from '../property/IgnoreEqualValuesProperty';
 
 /** @internal */
 type MinimalQualifiedParameters<Ts> = Pick<
   QualifiedParameters<Ts>,
-  'unbiased' | 'timeout' | 'skipAllAfterTimeLimit' | 'interruptAfterTimeLimit'
+  'unbiased' | 'timeout' | 'skipAllAfterTimeLimit' | 'interruptAfterTimeLimit' | 'ignoreEqualValues'
 >;
 
 /** @internal */
@@ -22,5 +23,8 @@ export function decorateProperty<Ts>(
     prop = new SkipAfterProperty(prop, Date.now, qParams.skipAllAfterTimeLimit, false);
   if (qParams.interruptAfterTimeLimit != null)
     prop = new SkipAfterProperty(prop, Date.now, qParams.interruptAfterTimeLimit, true);
+  if (qParams.ignoreEqualValues) {
+    prop = new IgnoreEqualValuesProperty(prop);
+  }
   return prop;
 }
diff --git a/src/check/runner/configuration/Parameters.ts b/src/check/runner/configuration/Parameters.ts
index d47f667b..1deb6649 100644
--- a/src/check/runner/configuration/Parameters.ts
+++ b/src/check/runner/configuration/Parameters.ts
@@ -91,6 +91,13 @@ export interface Parameters<T = void> {
    * @remarks Since 1.19.0
    */
   markInterruptAsFailure?: boolean;
+  /**
+   * Do not repeat runs with already covered cases.
+   * This is useful when arbitrary has a limited number of variants.
+   *
+   * NOTE: Values are compared by equality of fc.stringify results.
+   */
+  ignoreEqualValues?: boolean;
   /**
    * Way to replay a failing property directly with the counterexample.
    * It can be fed with the counterexamplePath returned by the failing test (requires `seed` too).
diff --git a/src/check/runner/configuration/QualifiedParameters.ts b/src/check/runner/configuration/QualifiedParameters.ts
index 42f78d47..a12a215d 100644
--- a/src/check/runner/configuration/QualifiedParameters.ts
+++ b/src/check/runner/configuration/QualifiedParameters.ts
@@ -25,6 +25,7 @@ export class QualifiedParameters<T> {
   skipAllAfterTimeLimit: number | null;
   interruptAfterTimeLimit: number | null;
   markInterruptAsFailure: boolean;
+  ignoreEqualValues: boolean;
   reporter: ((runDetails: RunDetails<T>) => void) | null;
   asyncReporter: ((runDetails: RunDetails<T>) => Promise<void>) | null;
 
@@ -39,6 +40,7 @@ export class QualifiedParameters<T> {
     this.skipAllAfterTimeLimit = QualifiedParameters.readOrDefault(p, 'skipAllAfterTimeLimit', null);
     this.interruptAfterTimeLimit = QualifiedParameters.readOrDefault(p, 'interruptAfterTimeLimit', null);
     this.markInterruptAsFailure = QualifiedParameters.readBoolean(p, 'markInterruptAsFailure');
+    this.ignoreEqualValues = QualifiedParameters.readBoolean(p, 'ignoreEqualValues');
     this.logger = QualifiedParameters.readOrDefault(p, 'logger', (v: string) => {
       // tslint:disable-next-line:no-console
       console.log(v);
@@ -62,6 +64,7 @@ export class QualifiedParameters<T> {
       skipAllAfterTimeLimit: orUndefined(this.skipAllAfterTimeLimit),
       interruptAfterTimeLimit: orUndefined(this.interruptAfterTimeLimit),
       markInterruptAsFailure: this.markInterruptAsFailure,
+      ignoreEqualValues: this.ignoreEqualValues,
       path: this.path,
       logger: this.logger,
       unbiased: this.unbiased,


Test patch:
diff --git a/test/e2e/IgnoreEqualValues.spec.ts b/test/e2e/IgnoreEqualValues.spec.ts
new file mode 100644
index 00000000..f276abe1
--- /dev/null
+++ b/test/e2e/IgnoreEqualValues.spec.ts
@@ -0,0 +1,21 @@
+import { seed } from './seed';
+import * as fc from '../../src/fast-check';
+
+describe(`IgnoreEqualValues (seed: ${seed})`, () => {
+  it('should not run more than 4 times', () => {
+    let numRuns = 0;
+    const out = fc.check(
+      fc.property(fc.boolean(), fc.boolean(), () => {
+        ++numRuns;
+        return true;
+      }),
+      { ignoreEqualValues: true }
+    );
+    expect(out.failed).toBe(false);
+    expect(out.interrupted).toBe(false);
+    expect(out.numRuns).toBe(100);
+    expect(out.numShrinks).toBe(0);
+    expect(out.numSkips).toBe(0);
+    expect(numRuns).toBeLessThanOrEqual(4);
+  });
+});
diff --git a/test/unit/check/property/IgnoreEqualValuesProperty.spec.ts b/test/unit/check/property/IgnoreEqualValuesProperty.spec.ts
new file mode 100644
index 00000000..11c942e5
--- /dev/null
+++ b/test/unit/check/property/IgnoreEqualValuesProperty.spec.ts
@@ -0,0 +1,31 @@
+import { IRawProperty } from '../../../../src/check/property/IRawProperty';
+import { IgnoreEqualValuesProperty } from '../../../../src/check/property/IgnoreEqualValuesProperty';
+
+function buildProperty() {
+  const mocks = {
+    isAsync: jest.fn(),
+    generate: jest.fn(),
+    run: jest.fn(),
+  };
+  return { mocks, property: mocks as IRawProperty<any> };
+}
+
+describe('IgnoreEqualValuesProperty', () => {
+  it('should not run decorated property when property is run on the same value', async () => {
+    const { mocks: propertyMock, property: decoratedProperty } = buildProperty();
+    const property = new IgnoreEqualValuesProperty(decoratedProperty);
+    property.run(1);
+    property.run(1);
+
+    expect(propertyMock.run.mock.calls.length).toBe(1);
+  });
+
+  it('should run decorated property when property is run on another value', async () => {
+    const { mocks: propertyMock, property: decoratedProperty } = buildProperty();
+    const property = new IgnoreEqualValuesProperty(decoratedProperty);
+    property.run(1);
+    property.run(2);
+
+    expect(propertyMock.run.mock.calls.length).toBe(2);
+  });
+});
diff --git a/test/unit/check/runner/DecorateProperty.spec.ts b/test/unit/check/runner/DecorateProperty.spec.ts
index 479271ce..9fe9a5a9 100644
--- a/test/unit/check/runner/DecorateProperty.spec.ts
+++ b/test/unit/check/runner/DecorateProperty.spec.ts
@@ -6,9 +6,11 @@ import { Shrinkable } from '../../../../src/check/arbitrary/definition/Shrinkabl
 import { SkipAfterProperty } from '../../../../src/check/property/SkipAfterProperty';
 import { TimeoutProperty } from '../../../../src/check/property/TimeoutProperty';
 import { UnbiasedProperty } from '../../../../src/check/property/UnbiasedProperty';
+import { IgnoreEqualValuesProperty } from '../../../../src/check/property/IgnoreEqualValuesProperty';
 jest.mock('../../../../src/check/property/SkipAfterProperty');
 jest.mock('../../../../src/check/property/TimeoutProperty');
 jest.mock('../../../../src/check/property/UnbiasedProperty');
+jest.mock('../../../../src/check/property/IgnoreEqualValuesProperty');
 
 function buildProperty(asyncProp: boolean) {
   return {
@@ -23,6 +25,7 @@ describe('decorateProperty', () => {
     (SkipAfterProperty as any).mockClear();
     (TimeoutProperty as any).mockClear();
     (UnbiasedProperty as any).mockClear();
+    (IgnoreEqualValuesProperty as any).mockClear();
   });
   it('Should enable none when needed', () => {
     decorateProperty(buildProperty(true), {
@@ -30,10 +33,12 @@ describe('decorateProperty', () => {
       interruptAfterTimeLimit: null,
       timeout: null,
       unbiased: false,
+      ignoreEqualValues: false,
     });
     expect(SkipAfterProperty).toHaveBeenCalledTimes(0);
     expect(TimeoutProperty).toHaveBeenCalledTimes(0);
     expect(UnbiasedProperty).toHaveBeenCalledTimes(0);
+    expect(IgnoreEqualValuesProperty).toHaveBeenCalledTimes(0);
   });
   it('Should enable SkipAfterProperty on skipAllAfterTimeLimit', () => {
     decorateProperty(buildProperty(true), {
@@ -41,10 +46,12 @@ describe('decorateProperty', () => {
       interruptAfterTimeLimit: null,
       timeout: null,
       unbiased: false,
+      ignoreEqualValues: false,
     });
     expect(SkipAfterProperty).toHaveBeenCalledTimes(1);
     expect(TimeoutProperty).toHaveBeenCalledTimes(0);
     expect(UnbiasedProperty).toHaveBeenCalledTimes(0);
+    expect(IgnoreEqualValuesProperty).toHaveBeenCalledTimes(0);
   });
   it('Should enable SkipAfterProperty on interruptAfterTimeLimit', () => {
     decorateProperty(buildProperty(true), {
@@ -52,10 +59,12 @@ describe('decorateProperty', () => {
       interruptAfterTimeLimit: 1,
       timeout: null,
       unbiased: false,
+      ignoreEqualValues: false,
     });
     expect(SkipAfterProperty).toHaveBeenCalledTimes(1);
     expect(TimeoutProperty).toHaveBeenCalledTimes(0);
     expect(UnbiasedProperty).toHaveBeenCalledTimes(0);
+    expect(IgnoreEqualValuesProperty).toHaveBeenCalledTimes(0);
   });
   it('Should enable TimeoutProperty on timeout', () => {
     decorateProperty(buildProperty(true), {
@@ -63,10 +72,12 @@ describe('decorateProperty', () => {
       interruptAfterTimeLimit: null,
       timeout: 1,
       unbiased: false,
+      ignoreEqualValues: false,
     });
     expect(SkipAfterProperty).toHaveBeenCalledTimes(0);
     expect(TimeoutProperty).toHaveBeenCalledTimes(1);
     expect(UnbiasedProperty).toHaveBeenCalledTimes(0);
+    expect(IgnoreEqualValuesProperty).toHaveBeenCalledTimes(0);
   });
   it('Should enable UnbiasedProperty on unbiased', () => {
     decorateProperty(buildProperty(true), {
@@ -74,10 +85,12 @@ describe('decorateProperty', () => {
       interruptAfterTimeLimit: null,
       timeout: null,
       unbiased: true,
+      ignoreEqualValues: false,
     });
     expect(SkipAfterProperty).toHaveBeenCalledTimes(0);
     expect(TimeoutProperty).toHaveBeenCalledTimes(0);
     expect(UnbiasedProperty).toHaveBeenCalledTimes(1);
+    expect(IgnoreEqualValuesProperty).toHaveBeenCalledTimes(0);
   });
   it('Should not enable TimeoutProperty on synchronous property', () => {
     decorateProperty(buildProperty(false), {
@@ -85,10 +98,25 @@ describe('decorateProperty', () => {
       interruptAfterTimeLimit: null,
       timeout: 1,
       unbiased: false,
+      ignoreEqualValues: false,
     });
     expect(SkipAfterProperty).toHaveBeenCalledTimes(0);
     expect(TimeoutProperty).toHaveBeenCalledTimes(0);
     expect(UnbiasedProperty).toHaveBeenCalledTimes(0);
+    expect(IgnoreEqualValuesProperty).toHaveBeenCalledTimes(0);
+  });
+  it('Should enable IgnoreEqualValuesProperty on ignoreEqualValues', () => {
+    decorateProperty(buildProperty(true), {
+      skipAllAfterTimeLimit: null,
+      interruptAfterTimeLimit: null,
+      timeout: null,
+      unbiased: false,
+      ignoreEqualValues: true,
+    });
+    expect(SkipAfterProperty).toHaveBeenCalledTimes(0);
+    expect(TimeoutProperty).toHaveBeenCalledTimes(0);
+    expect(UnbiasedProperty).toHaveBeenCalledTimes(0);
+    expect(IgnoreEqualValuesProperty).toHaveBeenCalledTimes(1);
   });
   it('Should enable multiple wrappers when needed', () => {
     decorateProperty(buildProperty(true), {
@@ -96,9 +124,11 @@ describe('decorateProperty', () => {
       interruptAfterTimeLimit: 1,
       timeout: 1,
       unbiased: true,
+      ignoreEqualValues: true,
     });
     expect(SkipAfterProperty).toHaveBeenCalledTimes(2);
     expect(TimeoutProperty).toHaveBeenCalledTimes(1);
     expect(UnbiasedProperty).toHaveBeenCalledTimes(1);
+    expect(IgnoreEqualValuesProperty).toHaveBeenCalledTimes(1);
   });
 });