Skip to content

Commit 19d9cbc

Browse files
authored
✨ Support excluded min/max in double (#4100)
* ✨ Support excluded min/max in `double` Up-to-now, it was not possible to ask for double between min and max with either min or max or both not in the range. Following the request #4046, we thought about it and started to offer ways to do so. * versions * Update website/docs/core-blocks/arbitraries/primitives/number.md * without excluded * safer check * better check boundaries * better check
1 parent 76ca1b6 commit 19d9cbc

File tree

5 files changed

+142
-17
lines changed

5 files changed

+142
-17
lines changed

.yarn/versions/de5677c4.yml

+8
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"

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

+22-4
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,31 @@ const safeNaN = Number.NaN;
2525
*/
2626
export interface DoubleConstraints {
2727
/**
28-
* Lower bound for the generated 64-bit floats (included)
28+
* Lower bound for the generated 64-bit floats (included, see minExcluded to exclude it)
2929
* @defaultValue Number.NEGATIVE_INFINITY, -1.7976931348623157e+308 when noDefaultInfinity is true
3030
* @remarks Since 2.8.0
3131
*/
3232
min?: number;
3333
/**
34-
* Upper bound for the generated 64-bit floats (included)
34+
* Should the lower bound (aka min) be excluded?
35+
* Note: Excluding min=Number.NEGATIVE_INFINITY would result into having min set to -Number.MAX_VALUE.
36+
* @defaultValue false
37+
* @remarks Since 3.12.0
38+
*/
39+
minExcluded?: boolean;
40+
/**
41+
* Upper bound for the generated 64-bit floats (included, see maxExcluded to exclude it)
3542
* @defaultValue Number.POSITIVE_INFINITY, 1.7976931348623157e+308 when noDefaultInfinity is true
3643
* @remarks Since 2.8.0
3744
*/
3845
max?: number;
46+
/**
47+
* Should the upper bound (aka max) be excluded?
48+
* Note: Excluding max=Number.POSITIVE_INFINITY would result into having max set to Number.MAX_VALUE.
49+
* @defaultValue false
50+
* @remarks Since 3.12.0
51+
*/
52+
maxExcluded?: boolean;
3953
/**
4054
* By default, lower and upper bounds are -infinity and +infinity.
4155
* By setting noDefaultInfinity to true, you move those defaults to minimal and maximal finite values.
@@ -85,11 +99,15 @@ export function double(constraints: DoubleConstraints = {}): Arbitrary<number> {
8599
const {
86100
noDefaultInfinity = false,
87101
noNaN = false,
102+
minExcluded = false,
103+
maxExcluded = false,
88104
min = noDefaultInfinity ? -safeMaxValue : safeNegativeInfinity,
89105
max = noDefaultInfinity ? safeMaxValue : safePositiveInfinity,
90106
} = constraints;
91-
const minIndex = safeDoubleToIndex(min, 'min');
92-
const maxIndex = safeDoubleToIndex(max, 'max');
107+
const minIndexRaw = safeDoubleToIndex(min, 'min');
108+
const minIndex = minExcluded ? add64(minIndexRaw, Unit64) : minIndexRaw;
109+
const maxIndexRaw = safeDoubleToIndex(max, 'max');
110+
const maxIndex = maxExcluded ? substract64(maxIndexRaw, Unit64) : maxIndexRaw;
93111
if (isStrictlySmaller64(maxIndex, minIndex)) {
94112
// In other words: minIndex > maxIndex
95113
// Comparing min and max might be problematic in case min=+0 and max=-0

packages/fast-check/test/unit/arbitrary/__test-helpers__/FloatingPointHelpers.ts

+53-8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import * as fc from 'fast-check';
22
import { DoubleConstraints } from '../../../../src/arbitrary/double';
33
import { FloatConstraints } from '../../../../src/arbitrary/float';
4+
import { MAX_VALUE_32, floatToIndex } from '../../../../src/arbitrary/_internals/helpers/FloatHelpers';
5+
import { doubleToIndex } from '../../../../src/arbitrary/_internals/helpers/DoubleHelpers';
6+
import { substract64 } from '../../../../src/arbitrary/_internals/helpers/ArrayInt64';
47

58
export function float32raw(): fc.Arbitrary<number> {
69
return fc.integer().map((n32) => new Float32Array(new Int32Array([n32]).buffer)[0]);
@@ -17,48 +20,90 @@ export const defaultFloatRecordConstraints = {
1720
max: float32raw(),
1821
noDefaultInfinity: fc.boolean(),
1922
noNaN: fc.boolean(),
23+
minExcluded: fc.boolean(),
24+
maxExcluded: fc.boolean(),
2025
};
2126

2227
export const defaultDoubleRecordConstraints = {
2328
min: float64raw(),
2429
max: float64raw(),
2530
noDefaultInfinity: fc.boolean(),
2631
noNaN: fc.boolean(),
32+
minExcluded: fc.boolean(),
33+
maxExcluded: fc.boolean(),
2734
};
2835

2936
type ConstraintsInternalOut = FloatConstraints & DoubleConstraints;
3037
type ConstraintsInternal = {
3138
[K in keyof ConstraintsInternalOut]?: fc.Arbitrary<ConstraintsInternalOut[K]>;
3239
};
33-
function constraintsInternal(recordConstraints: ConstraintsInternal): fc.Arbitrary<ConstraintsInternalOut> {
40+
function constraintsInternal(
41+
recordConstraints: ConstraintsInternal,
42+
is32Bits: boolean
43+
): fc.Arbitrary<ConstraintsInternalOut> {
3444
return fc
3545
.record(recordConstraints, { withDeletedKeys: true })
36-
.filter((ct) => (ct.min === undefined || !Number.isNaN(ct.min)) && (ct.max === undefined || !Number.isNaN(ct.max)))
3746
.filter((ct) => {
38-
if (!ct.noDefaultInfinity) return true;
39-
if (ct.min === Number.POSITIVE_INFINITY && ct.max === undefined) return false;
40-
if (ct.min === undefined && ct.max === Number.NEGATIVE_INFINITY) return false;
41-
return true;
47+
// Forbid min and max to be NaN
48+
return (ct.min === undefined || !Number.isNaN(ct.min)) && (ct.max === undefined || !Number.isNaN(ct.max));
4249
})
4350
.map((ct) => {
51+
// Already valid ct, no min or no max: we just return it as-is
4452
if (ct.min === undefined || ct.max === undefined) return ct;
4553
const { min, max } = ct;
54+
// Already valid ct, min < max: we just return it as-is
4655
if (min < max) return ct;
56+
// Already valid ct, min <= max with -0 and 0 correctly ordered: we just return it as-is
4757
if (min === max && (min !== 0 || 1 / min <= 1 / max)) return ct;
58+
// We have to exchange min and max to get an ordered range
4859
return { ...ct, min: max, max: min };
60+
})
61+
.filter((ct) => {
62+
// No issue when automatically defaulting to +/-inf
63+
if (!ct.noDefaultInfinity) return true;
64+
// Invalid range, cannot have min==inf if max has to default to +max_value
65+
if (ct.min === Number.POSITIVE_INFINITY && ct.max === undefined) return false;
66+
// Invalid range, cannot have max=-inf if min has to default to -max_value
67+
if (ct.min === undefined && ct.max === Number.NEGATIVE_INFINITY) return false;
68+
return true;
69+
})
70+
.filter((ct) => {
71+
const defaultMax = ct.noDefaultInfinity ? (is32Bits ? MAX_VALUE_32 : Number.MAX_VALUE) : Number.POSITIVE_INFINITY;
72+
const min = ct.min !== undefined ? ct.min : -defaultMax;
73+
const max = ct.max !== undefined ? ct.max : defaultMax;
74+
// Illegal range, values cannot be "min < value <= min" or "min <= value < min" or "min < value < min"
75+
if ((ct.minExcluded || ct.maxExcluded) && min === max) return false;
76+
// Always valid range given min !== max if min=-inf or max=+inf
77+
if (ct.max === Number.POSITIVE_INFINITY || ct.min === Number.NEGATIVE_INFINITY) return true;
78+
if (ct.minExcluded && ct.maxExcluded) {
79+
if (is32Bits) {
80+
const minIndex = floatToIndex(min);
81+
const maxIndex = floatToIndex(max);
82+
const distance = maxIndex - minIndex;
83+
// Illegal range, no value in range if min and max are too close from each others and both excluded
84+
if (distance === 1) return false;
85+
} else {
86+
const minIndex = doubleToIndex(min);
87+
const maxIndex = doubleToIndex(max);
88+
const distance = substract64(maxIndex, minIndex);
89+
// Illegal range, no value in range if min and max are too close from each others and both excluded
90+
if (distance.data[0] === 0 && distance.data[1] === 1) return false;
91+
}
92+
}
93+
return true;
4994
});
5095
}
5196

5297
export function floatConstraints(
5398
recordConstraints: Partial<typeof defaultFloatRecordConstraints> = defaultFloatRecordConstraints
5499
): fc.Arbitrary<FloatConstraints> {
55-
return constraintsInternal(recordConstraints);
100+
return constraintsInternal(recordConstraints, true);
56101
}
57102

58103
export function doubleConstraints(
59104
recordConstraints: Partial<typeof defaultDoubleRecordConstraints> = defaultDoubleRecordConstraints
60105
): fc.Arbitrary<DoubleConstraints> {
61-
return constraintsInternal(recordConstraints);
106+
return constraintsInternal(recordConstraints, false);
62107
}
63108

64109
export function isStrictlySmaller(fa: number, fb: number): boolean {

packages/fast-check/test/unit/arbitrary/double.spec.ts

+53-5
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ describe('double', () => {
5555
);
5656
});
5757

58-
it('should accept any constraits defining min (not-NaN) equal to max', () => {
58+
it('should accept any constraints defining min (not-NaN) equal to max', () => {
5959
fc.assert(
6060
fc.property(
6161
float64raw(),
@@ -75,6 +75,32 @@ describe('double', () => {
7575
);
7676
});
7777

78+
it('should reject any constraints defining min (not-NaN) equal to max if one is exclusive', () => {
79+
fc.assert(
80+
fc.property(
81+
float64raw(),
82+
fc.record({ noDefaultInfinity: fc.boolean(), noNaN: fc.boolean() }, { withDeletedKeys: true }),
83+
fc.constantFrom('min', 'max', 'both'),
84+
(f, otherCt, exclusiveMode) => {
85+
// Arrange
86+
fc.pre(!Number.isNaN(f));
87+
spyArrayInt64();
88+
89+
// Act / Assert
90+
expect(() =>
91+
double({
92+
...otherCt,
93+
min: f,
94+
max: f,
95+
minExcluded: exclusiveMode === 'min' || exclusiveMode === 'both',
96+
maxExcluded: exclusiveMode === 'max' || exclusiveMode === 'both',
97+
})
98+
).toThrowError();
99+
}
100+
)
101+
);
102+
});
103+
78104
it('should reject NaN if specified for min', () => {
79105
// Arrange
80106
const arrayInt64 = spyArrayInt64();
@@ -123,9 +149,15 @@ describe('double', () => {
123149

124150
if (typeof BigInt !== 'undefined') {
125151
it('should properly convert integer value for index between min and max into its associated float value', () => {
152+
const withoutExcludedConstraints = {
153+
...defaultDoubleRecordConstraints,
154+
minExcluded: fc.constant(false),
155+
maxExcluded: fc.constant(false),
156+
};
157+
126158
fc.assert(
127159
fc.property(
128-
fc.option(doubleConstraints(), { nil: undefined }),
160+
fc.option(doubleConstraints(withoutExcludedConstraints), { nil: undefined }),
129161
fc.bigUintN(64),
130162
fc.option(fc.integer({ min: 2 }), { nil: undefined }),
131163
(ct, mod, biasFactor) => {
@@ -227,13 +259,15 @@ describe('double', () => {
227259
const { min, max } = minMaxForConstraints(ct);
228260
const minIndex = doubleToIndex(min);
229261
const maxIndex = doubleToIndex(max);
262+
const expectedMinIndex = ct.minExcluded ? add64(minIndex, Unit64) : minIndex;
263+
const expectedMaxIndex = ct.maxExcluded ? substract64(maxIndex, Unit64) : maxIndex;
230264

231265
// Act
232266
double(ct);
233267

234268
// Assert
235269
expect(arrayInt64).toHaveBeenCalledTimes(1);
236-
expect(arrayInt64).toHaveBeenCalledWith(minIndex, maxIndex);
270+
expect(arrayInt64).toHaveBeenCalledWith(expectedMinIndex, expectedMaxIndex);
237271
})
238272
);
239273
});
@@ -254,17 +288,31 @@ describe('double (integration)', () => {
254288
expect(v).not.toBe(Number.NaN); // should not produce NaN if explicitely asked not too
255289
}
256290
if (extra.min !== undefined && !Number.isNaN(v)) {
257-
expect(v).toBeGreaterThanOrEqual(extra.min); // should always be greater than min when specified
291+
if (extra.minExcluded) {
292+
expect(v).toBeGreaterThan(extra.min); // should always be strictly greater than min when specified
293+
} else {
294+
expect(v).toBeGreaterThanOrEqual(extra.min); // should always be greater than min when specified
295+
}
258296
}
259297
if (extra.max !== undefined && !Number.isNaN(v)) {
260-
expect(v).toBeLessThanOrEqual(extra.max); // should always be smaller than max when specified
298+
if (extra.maxExcluded) {
299+
expect(v).toBeLessThan(extra.max); // should always be strictly smaller than max when specified
300+
} else {
301+
expect(v).toBeLessThanOrEqual(extra.max); // should always be smaller than max when specified
302+
}
261303
}
262304
if (extra.noDefaultInfinity) {
263305
if (extra.min === undefined) {
264306
expect(v).not.toBe(Number.NEGATIVE_INFINITY); // should not produce -infinity when noInfinity and min unset
307+
if (extra.minExcluded) {
308+
expect(v).not.toBe(-Number.MAX_VALUE); // nor -max_value
309+
}
265310
}
266311
if (extra.max === undefined) {
267312
expect(v).not.toBe(Number.POSITIVE_INFINITY); // should not produce +infinity when noInfinity and max unset
313+
if (extra.minExcluded) {
314+
expect(v).not.toBe(Number.MAX_VALUE); // nor max_value
315+
}
268316
}
269317
}
270318
};

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

+6
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ The lower and upper bounds are included into the range of possible values.
187187

188188
- `min?` — default: `-∞` and `-Number.MAX_VALUE` when `noDefaultInfinity:true`_lower bound for the generated 32-bit floats (included)_
189189
- `max?` — default: `+∞` and `Number.MAX_VALUE` when `noDefaultInfinity:true`_upper bound for the generated 32-bit floats (included)_
190+
- `minExcluded?` — default: `false`_do not include `min` in the set of possible values_
191+
- `maxExcluded?` — default: `false`_do not include `max` in the set of possible values_
190192
- `noDefaultInfinity?` — default: `false`_use finite values for `min` and `max` by default_
191193
- `noNaN?` — default: `false`_do not generate `Number.NaN`_
192194

@@ -210,6 +212,10 @@ fc.double({ noDefaultInfinity: true, min: Number.NEGATIVE_INTEGER, max: Number.P
210212
// should not be set to -∞ and +∞. It does not forbid the user to explicitely set them to -∞ and +∞.
211213
// Examples of generated values: 7.593633990222606e-236, -5.74664305820822e+216, -1.243100551492039e-161, 1.797693134862313e+308, -1.7976931348623077e+308…
212214

215+
fc.double({ min: 0, max: 1, maxExcluded: true });
216+
// Note: All possible floating point values between 0 (included) and 1 (excluded)
217+
// Examples of generated values: 4.8016271592767985e-73, 4.8825963576686075e-55, 0.9999999999999967, 0.9999999999999959, 2.5e-322…
218+
213219
fc.tuple(fc.integer({ min: 0, max: (1 << 26) - 1 }), fc.integer({ min: 0, max: (1 << 27) - 1 }))
214220
.map((v) => (v[0] * Math.pow(2, 27) + v[1]) * Math.pow(2, -53))
215221
.noBias();

0 commit comments

Comments
 (0)