diff --git a/.changeset/curvy-dingos-sort.md b/.changeset/curvy-dingos-sort.md new file mode 100644 index 00000000000..6e26fce0827 --- /dev/null +++ b/.changeset/curvy-dingos-sort.md @@ -0,0 +1,5 @@ +--- +"fast-check": minor +--- + +⚡️ Faster `canShrinkWithoutContext` for constants diff --git a/packages/fast-check/src/arbitrary/_internals/ConstantArbitrary.ts b/packages/fast-check/src/arbitrary/_internals/ConstantArbitrary.ts index 2c4fb93f39d..8b33b3cb11a 100644 --- a/packages/fast-check/src/arbitrary/_internals/ConstantArbitrary.ts +++ b/packages/fast-check/src/arbitrary/_internals/ConstantArbitrary.ts @@ -3,11 +3,14 @@ import { Stream } from '../../stream/Stream'; import { Arbitrary } from '../../check/arbitrary/definition/Arbitrary'; import { Value } from '../../check/arbitrary/definition/Value'; import { cloneMethod, hasCloneMethod } from '../../check/symbols'; +import { Set, safeHas } from '../../utils/globals'; const safeObjectIs = Object.is; /** @internal */ export class ConstantArbitrary extends Arbitrary { + private fastValues: FastConstantValuesLookup | undefined; + constructor(readonly values: T[]) { super(); } @@ -20,12 +23,13 @@ export class ConstantArbitrary extends Arbitrary { return new Value(value, idx, () => value[cloneMethod]()); } canShrinkWithoutContext(value: unknown): value is T { - for (let idx = 0; idx !== this.values.length; ++idx) { - if (safeObjectIs(this.values[idx], value)) { - return true; - } + if (this.values.length === 1) { + return safeObjectIs(this.values[0], value); } - return false; + if (this.fastValues === undefined) { + this.fastValues = new FastConstantValuesLookup(this.values); + } + return this.fastValues.has(value); } shrink(value: T, context?: unknown): Stream> { if (context === 0 || safeObjectIs(value, this.values[0])) { @@ -34,3 +38,36 @@ export class ConstantArbitrary extends Arbitrary { return Stream.of(new Value(this.values[0], 0)); } } + +/** @internal */ +class FastConstantValuesLookup { + private readonly hasMinusZero: boolean; + private readonly hasPlusZero: boolean; + private readonly fastValues: Set; + + constructor(readonly values: T[]) { + this.fastValues = new Set(this.values); + + let hasMinusZero = false; + let hasPlusZero = false; + if (safeHas(this.fastValues, 0)) { + for (let idx = 0; idx !== this.values.length; ++idx) { + const value = this.values[idx]; + hasMinusZero = hasMinusZero || safeObjectIs(value, -0); + hasPlusZero = hasPlusZero || safeObjectIs(value, 0); + } + } + this.hasMinusZero = hasMinusZero; + this.hasPlusZero = hasPlusZero; + } + + has(value: unknown): value is T { + if (value === 0) { + if (safeObjectIs(value, 0)) { + return this.hasPlusZero; + } + return this.hasMinusZero; + } + return safeHas(this.fastValues, value); + } +} diff --git a/packages/fast-check/src/utils/globals.ts b/packages/fast-check/src/utils/globals.ts index 5769b532c80..2351639532e 100644 --- a/packages/fast-check/src/utils/globals.ts +++ b/packages/fast-check/src/utils/globals.ts @@ -268,6 +268,7 @@ export function safeToISOString(instance: Date): string { // Set const untouchedAdd = Set.prototype.add; +const untouchedHas = Set.prototype.has; function extractAdd(instance: Set) { try { return instance.add; @@ -275,12 +276,25 @@ function extractAdd(instance: Set) { return undefined; } } +function extractHas(instance: Set) { + try { + return instance.has; + } catch (err) { + return undefined; + } +} export function safeAdd(instance: Set, value: T): Set { if (extractAdd(instance) === untouchedAdd) { return instance.add(value); } return safeApply(untouchedAdd, instance, [value]); } +export function safeHas(instance: Set, value: T): boolean { + if (extractHas(instance) === untouchedHas) { + return instance.has(value); + } + return safeApply(untouchedHas, instance, [value]); +} // String diff --git a/packages/fast-check/test/unit/arbitrary/_internals/ConstantArbitrary.spec.ts b/packages/fast-check/test/unit/arbitrary/_internals/ConstantArbitrary.spec.ts index 63a46ce2dcc..dfff921bf58 100644 --- a/packages/fast-check/test/unit/arbitrary/_internals/ConstantArbitrary.spec.ts +++ b/packages/fast-check/test/unit/arbitrary/_internals/ConstantArbitrary.spec.ts @@ -113,7 +113,7 @@ describe('ConstantArbitrary', () => { }); describe('canShrinkWithoutContext', () => { - it("should mark value as 'canShrinkWithoutContext' whenever one of the original values is equal regarding Object.is", () => + it("should mark value as 'canShrinkWithoutContext' whenever one of the original values is equal regarding Object.is", () => { fc.assert( fc.property(fc.array(fc.anything(), { minLength: 1 }), fc.nat(), (values, mod) => { // Arrange @@ -126,7 +126,24 @@ describe('ConstantArbitrary', () => { // Assert expect(out).toBe(true); }), - )); + ); + }); + + it("should not mark value as 'canShrinkWithoutContext' if none of the original values is equal regarding Object.is", () => { + fc.assert( + fc.property(fc.uniqueArray(fc.anything(), { minLength: 2, comparator: 'SameValue' }), (values) => { + // Arrange + const [selectedValue, ...acceptedValues] = values; + + // Act + const arb = new ConstantArbitrary(acceptedValues); + const out = arb.canShrinkWithoutContext(selectedValue); // selectedValue is unique for SameValue comparison (Object.is) + + // Assert + expect(out).toBe(false); + }), + ); + }); it('should not detect values not equal regarding to Object.is', () => { // Arrange @@ -141,33 +158,43 @@ describe('ConstantArbitrary', () => { expect(out).toBe(false); // Object.is([], []) is falsy }); - it.each([{ source: -0 }, { source: 0 }, { source: 48 }])( - 'should not accept to shrink -$source if built with $source', - ({ source }) => { - // Arrange - const arb = new ConstantArbitrary([source]); - - // Act - const out = arb.canShrinkWithoutContext(-source); - - // Assert - expect(out).toBe(false); - }, - ); - - it.each([{ source: -0 }, { source: 0 }, { source: 48 }, { source: Number.NaN }])( - 'should accept to shrink $source if built with $source', - ({ source }) => { - // Arrange - const arb = new ConstantArbitrary([source]); - - // Act - const out = arb.canShrinkWithoutContext(source); - - // Assert - expect(out).toBe(true); - }, - ); + it.each([ + { source: -0, count: 1 }, + { source: 0, count: 1 }, + { source: 48, count: 1 }, + { source: -0, count: 25 }, + { source: 0, count: 25 }, + { source: 48, count: 25 }, + ])('should not accept to shrink -$source if built with $source (count: $count)', ({ source, count }) => { + // Arrange + const arb = new ConstantArbitrary(Array(count).fill(source)); + + // Act + const out = arb.canShrinkWithoutContext(-source); + + // Assert + expect(out).toBe(false); + }); + + it.each([ + { source: -0, count: 1 }, + { source: 0, count: 1 }, + { source: 48, count: 1 }, + { source: Number.NaN, count: 1 }, + { source: -0, count: 25 }, + { source: 0, count: 25 }, + { source: 48, count: 25 }, + { source: Number.NaN, count: 25 }, + ])('should accept to shrink $source if built with $source (count: $count)', ({ source, count }) => { + // Arrange + const arb = new ConstantArbitrary(Array(count).fill(source)); + + // Act + const out = arb.canShrinkWithoutContext(source); + + // Assert + expect(out).toBe(true); + }); }); describe('shrink', () => {