Skip to content

Commit 2dec597

Browse files
authored
✨ Add option to produce non-integer on double (#4917)
Related to #4831 I still have a bit of refinement to do around it, but I'll do in next PRs: - produced range is still too large - no integration layer for tests - floats **_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):_ ...
1 parent 35f1547 commit 2dec597

File tree

6 files changed

+289
-12
lines changed

6 files changed

+289
-12
lines changed

.yarn/versions/bb14e24d.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
releases:
2+
fast-check: minor
3+
4+
declined:
5+
- "@fast-check/ava"
6+
- "@fast-check/jest"
7+
- "@fast-check/vitest"
8+
- "@fast-check/worker"
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { DoubleConstraints } from '../../double';
2+
3+
const safeNegativeInfinity = Number.NEGATIVE_INFINITY;
4+
const safePositiveInfinity = Number.POSITIVE_INFINITY;
5+
const safeMaxValue = Number.MAX_VALUE;
6+
7+
// The last floating point value available with 64 bits floating point numbers is: 4503599627370495.5
8+
// The start of integers' world is: 4503599627370496 = 2**52 = 2**(significand_size_with_sign-1)
9+
export const maxNonIntegerValue = 4503599627370495.5;
10+
export const onlyIntegersAfterThisValue = 4503599627370496;
11+
12+
/**
13+
* Refine source constraints receive by a double to focus only on non-integer values.
14+
* @param constraints - Source constraints to be refined
15+
*/
16+
export function refineConstraintsForDoubleOnly(
17+
constraints: Omit<DoubleConstraints, 'noInteger'>,
18+
): Omit<DoubleConstraints, 'noInteger'> {
19+
const {
20+
noDefaultInfinity = false,
21+
minExcluded = false,
22+
maxExcluded = false,
23+
min = noDefaultInfinity ? -safeMaxValue : safeNegativeInfinity,
24+
max = noDefaultInfinity ? safeMaxValue : safePositiveInfinity,
25+
} = constraints;
26+
27+
const effectiveMin = minExcluded
28+
? min < -maxNonIntegerValue
29+
? -onlyIntegersAfterThisValue
30+
: Math.max(min, -maxNonIntegerValue)
31+
: min === safeNegativeInfinity
32+
? Math.max(min, -onlyIntegersAfterThisValue)
33+
: Math.max(min, -maxNonIntegerValue);
34+
const effectiveMax = maxExcluded
35+
? max > maxNonIntegerValue
36+
? onlyIntegersAfterThisValue
37+
: Math.min(max, maxNonIntegerValue)
38+
: max === safePositiveInfinity
39+
? Math.min(max, onlyIntegersAfterThisValue)
40+
: Math.min(max, maxNonIntegerValue);
41+
42+
const fullConstraints: Required<Omit<DoubleConstraints, 'noInteger'>> = {
43+
noDefaultInfinity: false, // already handled locally
44+
minExcluded, // exclusion still need to be applied
45+
maxExcluded,
46+
min: effectiveMin,
47+
max: effectiveMax,
48+
noNaN: constraints.noNaN || false,
49+
};
50+
return fullConstraints;
51+
}
52+
53+
export function doubleOnlyMapper(value: number): number {
54+
return value === onlyIntegersAfterThisValue
55+
? safePositiveInfinity
56+
: value === -onlyIntegersAfterThisValue
57+
? safeNegativeInfinity
58+
: value;
59+
}
60+
61+
export function doubleOnlyUnmapper(value: unknown): number {
62+
if (typeof value !== 'number') throw new Error('Unsupported type');
63+
return value === safePositiveInfinity
64+
? onlyIntegersAfterThisValue
65+
: value === safeNegativeInfinity
66+
? -onlyIntegersAfterThisValue
67+
: value;
68+
}

packages/fast-check/src/arbitrary/double.ts

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import {
1010
import { arrayInt64 } from './_internals/ArrayInt64Arbitrary';
1111
import type { Arbitrary } from '../check/arbitrary/definition/Arbitrary';
1212
import { doubleToIndex, indexToDouble } from './_internals/helpers/DoubleHelpers';
13+
import {
14+
doubleOnlyMapper,
15+
doubleOnlyUnmapper,
16+
refineConstraintsForDoubleOnly,
17+
} from './_internals/helpers/DoubleOnlyHelpers';
1318

19+
const safeNumberIsInteger = Number.isInteger;
1420
const safeNumberIsNaN = Number.isNaN;
1521

1622
const safeNegativeInfinity = Number.NEGATIVE_INFINITY;
@@ -63,6 +69,13 @@ export interface DoubleConstraints {
6369
* @remarks Since 2.8.0
6470
*/
6571
noNaN?: boolean;
72+
/**
73+
* When set to true, Number.isInteger(value) will be false for any generated value.
74+
* Note: -infinity and +infinity, or NaN can stil be generated except if you rejected them via another constraint.
75+
* @defaultValue false
76+
* @remarks Since 3.18.0
77+
*/
78+
noInteger?: boolean;
6679
}
6780

6881
/**
@@ -84,18 +97,13 @@ function unmapperDoubleToIndex(value: unknown): ArrayInt64 {
8497
return doubleToIndex(value);
8598
}
8699

87-
/**
88-
* For 64-bit floating point numbers:
89-
* - sign: 1 bit
90-
* - significand: 52 bits
91-
* - exponent: 11 bits
92-
*
93-
* @param constraints - Constraints to apply when building instances (since 2.8.0)
94-
*
95-
* @remarks Since 0.0.6
96-
* @public
97-
*/
98-
export function double(constraints: DoubleConstraints = {}): Arbitrary<number> {
100+
/** @internal */
101+
function numberIsNotInteger(value: number): boolean {
102+
return !safeNumberIsInteger(value);
103+
}
104+
105+
/** @internal */
106+
function anyDouble(constraints: Omit<DoubleConstraints, 'noInteger'>): Arbitrary<number> {
99107
const {
100108
noDefaultInfinity = false,
101109
noNaN = false,
@@ -137,3 +145,23 @@ export function double(constraints: DoubleConstraints = {}): Arbitrary<number> {
137145
},
138146
);
139147
}
148+
149+
/**
150+
* For 64-bit floating point numbers:
151+
* - sign: 1 bit
152+
* - significand: 52 bits
153+
* - exponent: 11 bits
154+
*
155+
* @param constraints - Constraints to apply when building instances (since 2.8.0)
156+
*
157+
* @remarks Since 0.0.6
158+
* @public
159+
*/
160+
export function double(constraints: DoubleConstraints = {}): Arbitrary<number> {
161+
if (!constraints.noInteger) {
162+
return anyDouble(constraints);
163+
}
164+
return anyDouble(refineConstraintsForDoubleOnly(constraints))
165+
.map(doubleOnlyMapper, doubleOnlyUnmapper)
166+
.filter(numberIsNotInteger);
167+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { describe, it, expect } from 'vitest';
2+
import * as fc from 'fast-check';
3+
import { doubleToIndex, indexToDouble } from '../../../../../src/arbitrary/_internals/helpers/DoubleHelpers';
4+
import {
5+
maxNonIntegerValue,
6+
onlyIntegersAfterThisValue,
7+
refineConstraintsForDoubleOnly,
8+
} from '../../../../../src/arbitrary/_internals/helpers/DoubleOnlyHelpers';
9+
import { add64 } from '../../../../../src/arbitrary/_internals/helpers/ArrayInt64';
10+
11+
describe('maxNonIntegerValue', () => {
12+
it('should be immediately followed by an integer', () => {
13+
// Arrange / Act
14+
const next = nextDouble(maxNonIntegerValue);
15+
16+
// Assert
17+
expect(Number.isInteger(next)).toBe(true);
18+
});
19+
20+
it('should be followed by a number immediatelly followed by an integer', () => {
21+
// Arrange / Act
22+
const next = nextDouble(maxNonIntegerValue);
23+
const nextNext = nextDouble(next);
24+
25+
// Assert
26+
expect(Number.isInteger(nextNext)).toBe(true);
27+
});
28+
29+
it('should be immediately followed by onlyIntegersAfterThisValue', () => {
30+
// Arrange / Act / Assert
31+
expect(nextDouble(maxNonIntegerValue)).toBe(onlyIntegersAfterThisValue);
32+
});
33+
});
34+
35+
describe('refineConstraintsForDoubleOnly', () => {
36+
describe('no excluded', () => {
37+
it('should properly refine default constraints', () => {
38+
// Arrange / Act / Assert
39+
expect(refineConstraintsForDoubleOnly({})).toEqual({
40+
minExcluded: false,
41+
min: -onlyIntegersAfterThisValue, // min included, but its value will be replaced by -inf in mapper
42+
maxExcluded: false,
43+
max: onlyIntegersAfterThisValue, // max included, but its value will be replaced by +inf in mapper
44+
noDefaultInfinity: false,
45+
noNaN: false,
46+
});
47+
});
48+
49+
it('should properly refine when constraints reject infinities', () => {
50+
// Arrange / Act / Assert
51+
expect(refineConstraintsForDoubleOnly({ noDefaultInfinity: true })).toEqual({
52+
minExcluded: false,
53+
min: -maxNonIntegerValue,
54+
maxExcluded: false,
55+
max: maxNonIntegerValue,
56+
noDefaultInfinity: false,
57+
noNaN: false,
58+
});
59+
});
60+
61+
it('should properly refine when constraints ask for onlyIntegersAfterThisValue or above (excluding infinite)', () => {
62+
fc.assert(
63+
fc.property(
64+
fc.double({ noDefaultInfinity: true, noNaN: true, min: onlyIntegersAfterThisValue }),
65+
(boundary) => {
66+
// Arrange / Act / Assert
67+
expect(refineConstraintsForDoubleOnly({ min: -boundary, max: boundary })).toEqual({
68+
minExcluded: false,
69+
min: -maxNonIntegerValue, // min has been adapted to better fit the float range
70+
maxExcluded: false,
71+
max: maxNonIntegerValue, // max has been adapted to better fit the float range
72+
noDefaultInfinity: false,
73+
noNaN: false,
74+
});
75+
},
76+
),
77+
);
78+
});
79+
80+
it('should properly refine when constraints ask for maxNonIntegerValue or below', () => {
81+
fc.assert(
82+
fc.property(fc.double({ noNaN: true, min: 1, max: maxNonIntegerValue }), (boundary) => {
83+
// Arrange / Act / Assert
84+
expect(refineConstraintsForDoubleOnly({ min: -boundary, max: boundary })).toEqual({
85+
minExcluded: false,
86+
min: -boundary, // min was already in the accepted range
87+
maxExcluded: false,
88+
max: boundary, // max was already in the accepted range
89+
noDefaultInfinity: false,
90+
noNaN: false,
91+
});
92+
}),
93+
);
94+
});
95+
});
96+
97+
describe('with excluded', () => {
98+
const excluded = { minExcluded: true, maxExcluded: true };
99+
100+
it('should properly refine default constraints', () => {
101+
// Arrange / Act / Assert
102+
expect(refineConstraintsForDoubleOnly({ ...excluded })).toEqual({
103+
minExcluded: true,
104+
min: -onlyIntegersAfterThisValue, // min excluded so it only starts at -maxNonIntegerValue
105+
maxExcluded: true,
106+
max: onlyIntegersAfterThisValue, /// min excluded so it only starts at -maxNonIntegerValue
107+
noDefaultInfinity: false,
108+
noNaN: false,
109+
});
110+
});
111+
112+
it('should properly refine when constraints reject infinities', () => {
113+
// Arrange / Act / Assert
114+
expect(refineConstraintsForDoubleOnly({ ...excluded, noDefaultInfinity: true })).toEqual({
115+
minExcluded: true,
116+
min: -onlyIntegersAfterThisValue, // min excluded so it only starts at -maxNonIntegerValue
117+
maxExcluded: true,
118+
max: onlyIntegersAfterThisValue, // min excluded so it only starts at -maxNonIntegerValue
119+
noDefaultInfinity: false,
120+
noNaN: false,
121+
});
122+
});
123+
124+
it('should properly refine when constraints ask for onlyIntegersAfterThisValue or above (excluding infinite)', () => {
125+
fc.assert(
126+
fc.property(
127+
fc.double({ noDefaultInfinity: true, noNaN: true, min: onlyIntegersAfterThisValue }),
128+
(boundary) => {
129+
// Arrange / Act / Assert
130+
expect(refineConstraintsForDoubleOnly({ ...excluded, min: -boundary, max: boundary })).toEqual({
131+
minExcluded: true,
132+
min: -onlyIntegersAfterThisValue, // min has been adapted to better fit the float range, values only starts at -maxNonIntegerValue
133+
maxExcluded: true,
134+
max: onlyIntegersAfterThisValue, // max has been adapted to better fit the float range, values only starts at maxNonIntegerValue
135+
noDefaultInfinity: false,
136+
noNaN: false,
137+
});
138+
},
139+
),
140+
);
141+
});
142+
143+
it('should properly refine when constraints ask for maxNonIntegerValue or below', () => {
144+
fc.assert(
145+
fc.property(fc.double({ noNaN: true, min: 1, max: maxNonIntegerValue }), (boundary) => {
146+
// Arrange / Act / Assert
147+
expect(refineConstraintsForDoubleOnly({ ...excluded, min: -boundary, max: boundary })).toEqual({
148+
minExcluded: true,
149+
min: -boundary, // min was already in the accepted range
150+
maxExcluded: true,
151+
max: boundary, // max was already in the accepted range
152+
noDefaultInfinity: false,
153+
noNaN: false,
154+
});
155+
}),
156+
);
157+
});
158+
});
159+
});
160+
161+
// Helpers
162+
163+
function nextDouble(value: number): number {
164+
const index = doubleToIndex(value);
165+
const nextIndex = add64(index, { sign: 1, data: [0, 1] });
166+
return indexToDouble(nextIndex);
167+
}

website/docs/core-blocks/arbitraries/composites/typed-array.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ Generate _Float64Array_
359359
- `max?` — default: `+∞` and `Number.MAX_VALUE` when `noDefaultInfinity:true`_upper bound for the generated 32-bit floats (included)_
360360
- `noDefaultInfinity?` — default: `false`_use finite values for `min` and `max` by default_
361361
- `noNaN?` — default: `false`_do not generate `Number.NaN`_
362+
- `noInteger?` — default: `false`_do not generate values matching `Number.isInteger`_
362363
- `minLength?` — default: `0`_minimal length (included)_
363364
- `maxLength?` — default: `0x7fffffff` [more](/docs/configuration/larger-entries-by-default/#size-explained)_maximal length (included)_
364365
- `size?` — default: `undefined` [more](/docs/configuration/larger-entries-by-default/#size-explained)_how large should the generated values be?_

website/docs/core-blocks/arbitraries/primitives/number.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ The lower and upper bounds are included into the range of possible values.
197197
- `maxExcluded?` — default: `false`_do not include `max` in the set of possible values_
198198
- `noDefaultInfinity?` — default: `false`_use finite values for `min` and `max` by default_
199199
- `noNaN?` — default: `false`_do not generate `Number.NaN`_
200+
- `noInteger?` — default: `false`_do not generate values matching `Number.isInteger`_
200201

201202
**Usages:**
202203

@@ -222,6 +223,10 @@ fc.double({ min: 0, max: 1, maxExcluded: true });
222223
// Note: All possible floating point values between 0 (included) and 1 (excluded)
223224
// Examples of generated values: 4.8016271592767985e-73, 4.8825963576686075e-55, 0.9999999999999967, 0.9999999999999959, 2.5e-322…
224225

226+
fc.double({ noInteger: true });
227+
// Note: All possible floating point values but no integer
228+
// Examples of generated values: -2.3e-322, -4503599627370495.5, -1.8524776326185756e-119, -9.4e-323, 7e-323…
229+
225230
fc.tuple(fc.integer({ min: 0, max: (1 << 26) - 1 }), fc.integer({ min: 0, max: (1 << 27) - 1 }))
226231
.map((v) => (v[0] * Math.pow(2, 27) + v[1]) * Math.pow(2, -53))
227232
.noBias();

0 commit comments

Comments
 (0)