Repository: dubzzz/fast-check
Pull request #1207 (2020-12-01 18:42:07)

✅ More stable coverage for floatNext
<!-- Why is this PR for? -->
<!-- Describe the reason why you opened this PR or give a link towards the associated issue. -->

## In a nutshell

❌ New feature
❌ Fix an issue
❌ Documentation improvement
✔️ Other: *coverage*

(✔️: yes, ❌: no)

## Potential impacts

None


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/1207/builds/78828) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 24cd6bd728bb50d4b099d5325a3c80aeededc94d:

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

coveralls: 
[![Coverage Status](https://coveralls.io/builds/35368487/badge)](https://coveralls.io/builds/35368487)

Coverage decreased (-0.03%) to 96.472% when pulling **8722305e33926105f4539b92b686768408908e10 on stable-cov-float-next** into **6d42acef57e58e43b0325a297869282759b68b8c on master**.


Patch:
diff --git a/src/check/arbitrary/FloatNextArbitrary.ts b/src/check/arbitrary/FloatNextArbitrary.ts
index 9c5dae4c..a55f3b78 100644
--- a/src/check/arbitrary/FloatNextArbitrary.ts
+++ b/src/check/arbitrary/FloatNextArbitrary.ts
@@ -196,7 +196,7 @@ export function floatNext(constraints: FloatNextConstraints = {}): Arbitrary<num
     throw new Error('fc.floatNext constraints.min must be smaller or equal to constraints.max');
   }
   if (noNaN) {
-    return integer(minIndex, maxIndex).map(indexToFloat);
+    return integer({ min: minIndex, max: maxIndex }).map(indexToFloat);
   }
   // In case maxIndex > 0 or in other words max > 0,
   //   values will be [min, ..., +0, ..., max, NaN]
@@ -205,7 +205,7 @@ export function floatNext(constraints: FloatNextConstraints = {}): Arbitrary<num
   //   values will be [NaN, min, ..., max] with max <= +0
   const minIndexWithNaN = maxIndex > 0 ? minIndex : minIndex - 1;
   const maxIndexWithNaN = maxIndex > 0 ? maxIndex + 1 : maxIndex;
-  return integer(minIndexWithNaN, maxIndexWithNaN).map((index) => {
+  return integer({ min: minIndexWithNaN, max: maxIndexWithNaN }).map((index) => {
     if (index > maxIndex || index < minIndex) return Number.NaN;
     else return indexToFloat(index);
   });


Test patch:
diff --git a/test/unit/check/arbitrary/FloatNextArbitrary.itest.spec.ts b/test/unit/check/arbitrary/FloatNextArbitrary.itest.spec.ts
new file mode 100644
index 00000000..065ccdc7
--- /dev/null
+++ b/test/unit/check/arbitrary/FloatNextArbitrary.itest.spec.ts
@@ -0,0 +1,34 @@
+import * as fc from '../../../../lib/fast-check';
+
+import { floatNext, FloatNextConstraints } from '../../../../src/check/arbitrary/FloatNextArbitrary';
+import { floatNextConstraints, is32bits } from './generic/FloatingPointHelpers';
+import * as genericHelper from './generic/GenericArbitraryHelper';
+
+describe('FloatNextArbitrary', () => {
+  describe('floatNext', () => {
+    describe('Is valid arbitrary?', () => {
+      genericHelper.isValidArbitrary((ct?: FloatNextConstraints) => floatNext(ct), {
+        isStrictlySmallerValue: (fa, fb) =>
+          Math.abs(fa) < Math.abs(fb) || //              Case 1: abs(a) < abs(b)
+          (Object.is(fa, +0) && Object.is(fb, -0)) || // Case 2: +0 < -0  --> we shrink from -0 to +0
+          (!Number.isNaN(fa) && Number.isNaN(fb)), //    Case 3: notNaN < NaN, NaN is one of the extreme values
+        isValidValue: (g: number, ct?: FloatNextConstraints) => {
+          if (typeof g !== 'number') return false; // should always produce numbers
+          if (!is32bits(g)) return false; // should always produce 32-bit floats
+          if (Number.isNaN(g)) {
+            if (ct !== undefined && ct.noNaN) return false; // should not produce NaN if explicitely asked not too
+            return true;
+          }
+          if (ct !== undefined && ct.min !== undefined && g < ct.min) return false; // should always be greater than min when specified
+          if (ct !== undefined && ct.max !== undefined && g > ct.max) return false; // should always be smaller than max when specified
+          if (ct !== undefined && ct.noDefaultInfinity) {
+            if (ct.min === undefined && g === Number.NEGATIVE_INFINITY) return false; // should not produce -infinity when noInfity and min unset
+            if (ct.max === undefined && g === Number.POSITIVE_INFINITY) return false; // should not produce +infinity when noInfity and max unset
+          }
+          return true;
+        },
+        seedGenerator: fc.option(floatNextConstraints(), { nil: undefined }),
+      });
+    });
+  });
+});
diff --git a/test/unit/check/arbitrary/FloatNextArbitrary.spec.ts b/test/unit/check/arbitrary/FloatNextArbitrary.utest.spec.ts
similarity index 59%
rename from test/unit/check/arbitrary/FloatNextArbitrary.spec.ts
rename to test/unit/check/arbitrary/FloatNextArbitrary.utest.spec.ts
index c625d137..1b5beafe 100644
--- a/test/unit/check/arbitrary/FloatNextArbitrary.spec.ts
+++ b/test/unit/check/arbitrary/FloatNextArbitrary.utest.spec.ts
@@ -10,52 +10,61 @@ import {
   floatNext,
   FloatNextConstraints,
 } from '../../../../src/check/arbitrary/FloatNextArbitrary';
-import * as genericHelper from './generic/GenericArbitraryHelper';
-
-const float32raw = () => {
-  return fc.integer().map((n32) => new Float32Array(new Int32Array([n32]).buffer)[0]);
-};
-const float64raw = () => {
-  return fc
-    .tuple(fc.integer(), fc.integer())
-    .map(([na32, nb32]) => new Float64Array(new Int32Array([na32, nb32]).buffer)[0]);
-};
-const floatNextConstraints = () => {
-  return fc
-    .record(
-      { min: float32raw(), max: float32raw(), noDefaultInfinity: fc.boolean(), noNaN: fc.boolean() },
-      { withDeletedKeys: true }
-    )
-    .filter((ct) => (ct.min === undefined || !Number.isNaN(ct.min)) && (ct.max === undefined || !Number.isNaN(ct.max)))
-    .filter((ct) => {
-      if (!ct.noDefaultInfinity) return true;
-      if (ct.min === Number.POSITIVE_INFINITY && ct.max === undefined) return false;
-      if (ct.min === undefined && ct.max === Number.NEGATIVE_INFINITY) return false;
-      return true;
-    })
-    .map((ct) => {
-      if (ct.min === undefined || ct.max === undefined) return ct;
-      const { min, max } = ct;
-      if (min < max) return ct;
-      if (min === max && (min !== 0 || 1 / min <= 1 / max)) return ct;
-      return { ...ct, min: max, max: min };
-    });
-};
+import {
+  floatNextConstraints,
+  float32raw,
+  isNotNaN32bits,
+  float64raw,
+  isStrictlySmaller,
+  isFiniteNotNaN32bits,
+  defaultRecordConstraints,
+} from './generic/FloatingPointHelpers';
+
+import { mocked } from 'ts-jest/utils';
+import { arbitraryFor } from './generic/ArbitraryBuilder';
+import * as stubRng from '../../stubs/generators';
+
+import * as IntegerArbitraryMock from '../../../../src/check/arbitrary/IntegerArbitrary';
+jest.mock('../../../../src/check/arbitrary/IntegerArbitrary');
 
-const isStrictlySmaller = (fa: number, fb: number) => {
-  if (fa === 0 && fb === 0) return 1 / fa < 1 / fb;
-  return fa < fb;
-};
+const mrng = () => stubRng.mutable.nocall();
 
-const is32bits = (f64: number) => Object.is(new Float32Array([f64])[0], f64);
-const isNotNaN32bits = (f64: number) => !Number.isNaN(f64) && is32bits(f64);
-const isFiniteNotNaN32bits = (f64: number) => Number.isFinite(f64) && isNotNaN32bits(f64);
+function minMaxForConstraints(ct: FloatNextConstraints) {
+  const noDefaultInfinity = ct.noDefaultInfinity;
+  const {
+    min = noDefaultInfinity ? -MAX_VALUE_32 : Number.NEGATIVE_INFINITY,
+    max = noDefaultInfinity ? MAX_VALUE_32 : Number.POSITIVE_INFINITY,
+  } = ct;
+  return { min, max };
+}
+
+function mockNoOpIntegerArb(opts: { single?: boolean } = {}) {
+  // Mocking integer: not expecting any call there
+  const { integer } = mocked(IntegerArbitraryMock);
+  if (opts.single) integer.mockImplementationOnce(() => arbitraryFor([]));
+  else integer.mockImplementation(() => arbitraryFor([]));
+  return integer;
+}
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  jest.resetAllMocks();
+});
+const previousGlobal = fc.readConfigureGlobal();
+fc.configureGlobal({
+  ...previousGlobal,
+  beforeEach: () => {
+    jest.clearAllMocks();
+    jest.resetAllMocks();
+  },
+});
 
 describe('FloatNextArbitrary', () => {
   describe('floatNext', () => {
     it('Should accept any valid range of 32-bit floating point numbers (including infinity)', () =>
       fc.assert(
         fc.property(floatNextConstraints(), (ct) => {
+          mockNoOpIntegerArb();
           expect(floatNext(ct)).toBeDefined();
         })
       ));
@@ -66,6 +75,7 @@ describe('FloatNextArbitrary', () => {
           fc.record({ noDefaultInfinity: fc.boolean(), noNaN: fc.boolean() }, { withDeletedKeys: true }),
           (f, otherCt) => {
             fc.pre(isNotNaN32bits(f));
+            mockNoOpIntegerArb();
             expect(floatNext({ ...otherCt, min: f, max: f })).toBeDefined();
           }
         )
@@ -74,6 +84,7 @@ describe('FloatNextArbitrary', () => {
       fc.assert(
         fc.property(float64raw(), (f64) => {
           fc.pre(!isNotNaN32bits(f64));
+          mockNoOpIntegerArb();
           expect(() => floatNext({ min: f64 })).toThrowError();
         })
       ));
@@ -81,6 +92,7 @@ describe('FloatNextArbitrary', () => {
       fc.assert(
         fc.property(float64raw(), (f64) => {
           fc.pre(!isNotNaN32bits(f64));
+          mockNoOpIntegerArb();
           expect(() => floatNext({ max: f64 })).toThrowError();
         })
       ));
@@ -90,38 +102,115 @@ describe('FloatNextArbitrary', () => {
           fc.pre(isNotNaN32bits(fa32));
           fc.pre(isNotNaN32bits(fb32));
           fc.pre(!Object.is(fa32, fb32)); // Object.is can distinguish -0 from 0, while !== cannot
+          mockNoOpIntegerArb();
           const min = isStrictlySmaller(fa32, fb32) ? fb32 : fa32;
           const max = isStrictlySmaller(fa32, fb32) ? fa32 : fb32;
           expect(() => floatNext({ min, max })).toThrowError();
         })
       ));
     it('Should reject impossible noDefaultInfinity-based ranges', () => {
+      mockNoOpIntegerArb();
       expect(() => floatNext({ min: Number.POSITIVE_INFINITY, noDefaultInfinity: true })).toThrowError();
       expect(() => floatNext({ max: Number.NEGATIVE_INFINITY, noDefaultInfinity: true })).toThrowError();
     });
-    describe('Is valid arbitrary?', () => {
-      genericHelper.isValidArbitrary((ct?: FloatNextConstraints) => floatNext(ct), {
-        isStrictlySmallerValue: (fa, fb) =>
-          Math.abs(fa) < Math.abs(fb) || //              Case 1: abs(a) < abs(b)
-          (Object.is(fa, +0) && Object.is(fb, -0)) || // Case 2: +0 < -0  --> we shrink from -0 to +0
-          (!Number.isNaN(fa) && Number.isNaN(fb)), //    Case 3: notNaN < NaN, NaN is one of the extreme values
-        isValidValue: (g: number, ct?: FloatNextConstraints) => {
-          if (typeof g !== 'number') return false; // should always produce numbers
-          if (!is32bits(g)) return false; // should always produce 32-bit floats
-          if (Number.isNaN(g)) {
-            if (ct !== undefined && ct.noNaN) return false; // should not produce NaN if explicitely asked not too
-            return true;
-          }
-          if (ct !== undefined && ct.min !== undefined && g < ct.min) return false; // should always be greater than min when specified
-          if (ct !== undefined && ct.max !== undefined && g > ct.max) return false; // should always be smaller than max when specified
-          if (ct !== undefined && ct.noDefaultInfinity) {
-            if (ct.min === undefined && g === Number.NEGATIVE_INFINITY) return false; // should not produce -infinity when noInfity and min unset
-            if (ct.max === undefined && g === Number.POSITIVE_INFINITY) return false; // should not produce +infinity when noInfity and max unset
-          }
-          return true;
-        },
-        seedGenerator: fc.option(floatNextConstraints(), { nil: undefined }),
-      });
+    it('Should properly convert integer value for index between min and max into its associated float value', () =>
+      fc.assert(
+        fc.property(fc.option(floatNextConstraints(), { nil: undefined }), fc.maxSafeNat(), (ct, mod) => {
+          // Arrange
+          const { integer } = mocked(IntegerArbitraryMock);
+          const { min, max } = minMaxForConstraints(ct || {});
+          const minIndex = floatToIndex(min);
+          const maxIndex = floatToIndex(max);
+          const arbitraryGeneratedIndex = (mod % (maxIndex - minIndex + 1)) + minIndex;
+          integer.mockImplementationOnce(() => arbitraryFor([{ value: arbitraryGeneratedIndex }]));
+
+          // Act
+          const arb = floatNext(ct);
+          const { value_: f } = arb.generate(mrng());
+
+          // Assert
+          expect(f).toBe(indexToFloat(arbitraryGeneratedIndex));
+        })
+      ));
+
+    describe('with NaN', () => {
+      const withNaNRecordConstraints = { ...defaultRecordConstraints, noNaN: fc.constant(false) };
+
+      it('Should ask for a range with one extra value (far from zero)', () =>
+        fc.assert(
+          fc.property(floatNextConstraints(withNaNRecordConstraints), fc.maxSafeInteger(), (ct) => {
+            // Arrange
+            const { max } = minMaxForConstraints(ct);
+            const integer = mockNoOpIntegerArb();
+
+            // Act
+            floatNext({ ...ct, noNaN: true });
+            floatNext(ct);
+
+            // Assert
+            expect(integer).toHaveBeenCalledTimes(2);
+            const integerConstraintsNoNaN = integer.mock.calls[0][0];
+            const integerConstraintsWithNaN = integer.mock.calls[1][0];
+            if (max > 0) {
+              // max > 0  --> NaN will be added as the greatest value
+              expect(integerConstraintsWithNaN.min).toBe(integerConstraintsNoNaN.min);
+              expect(integerConstraintsWithNaN.max).toBe(integerConstraintsNoNaN.max! + 1);
+            } else {
+              // max <= 0 --> NaN will be added as the smallest value
+              expect(integerConstraintsWithNaN.min).toBe(integerConstraintsNoNaN.min! - 1);
+              expect(integerConstraintsWithNaN.max).toBe(integerConstraintsNoNaN.max);
+            }
+          })
+        ));
+      it('Should properly convert the extra value to NaN', () =>
+        fc.assert(
+          fc.property(floatNextConstraints(withNaNRecordConstraints), fc.maxSafeNat(), (ct) => {
+            // Arrange
+            // Setup mocks for integer
+            const arbitraryGenerated = { value: Number.NaN };
+            const integer = mockNoOpIntegerArb({ single: true });
+            integer.mockImplementationOnce(() => arbitraryFor([arbitraryGenerated]));
+            // Call float next to find out the value required for NaN
+            floatNext({ ...ct, noNaN: true });
+            const arb = floatNext(ct);
+            // Extract NaN "index"
+            const { min: minNonNaN } = integer.mock.calls[0][0];
+            const { min: minNaN, max: maxNaN } = integer.mock.calls[1][0];
+            const indexForNaN = minNonNaN !== minNaN ? minNaN : maxNaN;
+            if (indexForNaN === undefined) throw new Error('No value available for NaN');
+            arbitraryGenerated.value = indexForNaN;
+
+            // Act
+            const { value_: f } = arb.generate(mrng());
+
+            // Assert
+            expect(f).toBe(Number.NaN);
+          })
+        ));
+    });
+
+    describe('without NaN', () => {
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+      const { noNaN, ...noNaNRecordConstraints } = defaultRecordConstraints;
+
+      it('Should ask integers between the indexes corresponding to min and max', () =>
+        fc.assert(
+          fc.property(floatNextConstraints(noNaNRecordConstraints), (ctDraft) => {
+            // Arrange
+            const ct = { ...ctDraft, noNaN: true };
+            const integer = mockNoOpIntegerArb();
+            const { min, max } = minMaxForConstraints(ct);
+            const minIndex = floatToIndex(min);
+            const maxIndex = floatToIndex(max);
+
+            // Act
+            floatNext(ct);
+
+            // Assert
+            expect(integer).toHaveBeenCalledTimes(1);
+            expect(integer).toHaveBeenCalledWith({ min: minIndex, max: maxIndex });
+          })
+        ));
     });
   });
 
diff --git a/test/unit/check/arbitrary/generic/FloatingPointHelpers.ts b/test/unit/check/arbitrary/generic/FloatingPointHelpers.ts
new file mode 100644
index 00000000..31399dda
--- /dev/null
+++ b/test/unit/check/arbitrary/generic/FloatingPointHelpers.ts
@@ -0,0 +1,57 @@
+import * as fc from '../../../../../lib/fast-check';
+import { FloatNextConstraints } from '../../../../../src/check/arbitrary/FloatNextArbitrary';
+
+export function float32raw(): fc.Arbitrary<number> {
+  return fc.integer().map((n32) => new Float32Array(new Int32Array([n32]).buffer)[0]);
+}
+
+export function float64raw(): fc.Arbitrary<number> {
+  return fc
+    .tuple(fc.integer(), fc.integer())
+    .map(([na32, nb32]) => new Float64Array(new Int32Array([na32, nb32]).buffer)[0]);
+}
+
+export const defaultRecordConstraints = {
+  min: float32raw(),
+  max: float32raw(),
+  noDefaultInfinity: fc.boolean(),
+  noNaN: fc.boolean(),
+};
+
+export function floatNextConstraints(
+  recordConstraints: Partial<typeof defaultRecordConstraints> = defaultRecordConstraints
+): fc.Arbitrary<FloatNextConstraints> {
+  return fc
+    .record(recordConstraints, { withDeletedKeys: true })
+    .filter((ct) => (ct.min === undefined || !Number.isNaN(ct.min)) && (ct.max === undefined || !Number.isNaN(ct.max)))
+    .filter((ct) => {
+      if (!ct.noDefaultInfinity) return true;
+      if (ct.min === Number.POSITIVE_INFINITY && ct.max === undefined) return false;
+      if (ct.min === undefined && ct.max === Number.NEGATIVE_INFINITY) return false;
+      return true;
+    })
+    .map((ct) => {
+      if (ct.min === undefined || ct.max === undefined) return ct;
+      const { min, max } = ct;
+      if (min < max) return ct;
+      if (min === max && (min !== 0 || 1 / min <= 1 / max)) return ct;
+      return { ...ct, min: max, max: min };
+    });
+}
+
+export function isStrictlySmaller(fa: number, fb: number): boolean {
+  if (fa === 0 && fb === 0) return 1 / fa < 1 / fb;
+  return fa < fb;
+}
+
+export function is32bits(f64: number): boolean {
+  return Object.is(new Float32Array([f64])[0], f64);
+}
+
+export function isNotNaN32bits(f64: number): boolean {
+  return !Number.isNaN(f64) && is32bits(f64);
+}
+
+export function isFiniteNotNaN32bits(f64: number): boolean {
+  return Number.isFinite(f64) && isNotNaN32bits(f64);
+}