diff --git a/packages/fast-check/src/arbitrary/_internals/helpers/MaxLengthFromMinLength.ts b/packages/fast-check/src/arbitrary/_internals/helpers/MaxLengthFromMinLength.ts index aa9723ca9dd..76f0093c8ed 100644 --- a/packages/fast-check/src/arbitrary/_internals/helpers/MaxLengthFromMinLength.ts +++ b/packages/fast-check/src/arbitrary/_internals/helpers/MaxLengthFromMinLength.ts @@ -192,3 +192,19 @@ export function resolveSize(size: Exclude | undefined): } return relativeSizeToSize(size, defaultSize); } + +/** @internal */ +export function invertSize(size: Size): Size { + switch (size) { + case 'xsmall': + return 'xlarge'; + case 'small': + return 'large'; + case 'medium': + return 'medium'; + case 'large': + return 'small'; + case 'xlarge': + return 'xsmall'; + } +} diff --git a/packages/fast-check/src/arbitrary/letrec.ts b/packages/fast-check/src/arbitrary/letrec.ts index 36e10c6c63e..64967c8d22f 100644 --- a/packages/fast-check/src/arbitrary/letrec.ts +++ b/packages/fast-check/src/arbitrary/letrec.ts @@ -1,8 +1,15 @@ import { LazyArbitrary } from './_internals/LazyArbitrary'; import type { Arbitrary } from '../check/arbitrary/definition/Arbitrary'; -import { safeHasOwnProperty } from '../utils/globals'; +import { safeAdd, safeHas, safeHasOwnProperty } from '../utils/globals'; +import { invertSize, resolveSize, type SizeForArbitrary } from './_internals/helpers/MaxLengthFromMinLength'; +import { nat } from './nat.js'; +import { record } from './record.js'; +import { array } from './array.js'; +import { noShrink } from './noShrink.js'; +const safeArrayIsArray = Array.isArray; const safeObjectCreate = Object.create; +const safeObjectEntries = Object.entries; /** * Type of the value produced by {@link letrec} @@ -50,6 +57,154 @@ export type LetrecLooselyTypedTie = (key: string) => Arbitrary; */ export type LetrecLooselyTypedBuilder = (tie: LetrecLooselyTypedTie) => LetrecValue; +/** + * Constraints to be applied on {@link letrec} + * @remarks Since 4.4.0 + * @public + */ +export interface LetrecConstraints { + /** + * Generate objects with circular references + * @defaultValue false + * @remarks Since 4.4.0 + */ + withCycles?: boolean | CycleConstraints; +} + +/** + * Constraints to be applied on {@link LetrecConstraints.withCycles} + * @remarks Since 4.4.0 + * @public + */ +export interface CycleConstraints { + /** + * Define how frequently cycles should occur in the generated values (at max) + * @remarks Since 4.4.0 + */ + frequencySize?: Exclude; +} + +/** @internal */ +function letrecWithoutCycles(builder: LetrecLooselyTypedBuilder | LetrecTypedBuilder): LetrecValue { + const lazyArbs: { [K in keyof T]?: LazyArbitrary } = safeObjectCreate(null); + const tie = (key: keyof T): Arbitrary => { + if (!safeHasOwnProperty(lazyArbs, key)) { + // Call to hasOwnProperty ensures that the property key will be defined + lazyArbs[key] = new LazyArbitrary(String(key)); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return lazyArbs[key]!; + }; + const strictArbs = builder(tie as any); + for (const key in strictArbs) { + if (!safeHasOwnProperty(strictArbs, key)) { + // Prevents accidental iteration over properties inherited from an object’s prototype + continue; + } + const lazyAtKey: LazyArbitrary | undefined = lazyArbs[key]; + const lazyArb = lazyAtKey !== undefined ? lazyAtKey : new LazyArbitrary(key); + lazyArb.underlying = strictArbs[key]; + lazyArbs[key] = lazyArb; + } + return strictArbs; +} + +/** @internal */ +export function derefPools(pools: { [K in keyof T]: unknown[] }, placeholderSymbol: symbol): void { + const visited = new Set(); + function deref(value: unknown, source?: Record, sourceKey?: PropertyKey) { + if (typeof value !== 'object' || value === null) { + return; + } + + if (safeHas(visited, value)) { + return; + } + safeAdd(visited, value); + + if (safeHasOwnProperty(value, placeholderSymbol)) { + // This is a while loop because it's possible for an arbitrary to be defined as just `arb: tie('otherArb')`, in + // which case what the `arb` generates is also a placeholder. + let currentValue: unknown = value; + do { + const { key, index } = (currentValue as { [placeholderSymbol]: { key: keyof T; index: number } })[ + placeholderSymbol + ]; + const pool = pools[key]; + currentValue = pool[index % pool.length]; + if (source !== undefined && sourceKey !== undefined) { + source[sourceKey] = currentValue; + } + } while (safeHasOwnProperty(currentValue, placeholderSymbol)); + return; + } + + if (safeArrayIsArray(value)) { + for (let i = 0; i < value.length; i++) { + deref(value[i], value as unknown as Record, i); + } + } else { + for (const [key, item] of safeObjectEntries(value)) { + deref(item, value as Record, key); + } + } + } + deref(pools); +} + +/** @internal */ +function letrecWithCycles( + builder: LetrecLooselyTypedBuilder | LetrecTypedBuilder, + constraints: CycleConstraints, +): LetrecValue { + const lazyArbs: { [K in keyof T]?: LazyArbitrary } = safeObjectCreate(null); + const tie = (key: keyof T): Arbitrary => { + if (!safeHasOwnProperty(lazyArbs, key)) { + // Call to hasOwnProperty ensures that the property key will be defined + lazyArbs[key] = new LazyArbitrary(String(key)); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return lazyArbs[key]!; + }; + const strictArbs = builder(tie as any); + + // Symbol to replace with a potentially circular reference later. + const placeholderSymbol = Symbol('placeholder'); + const poolArbs: { [K in keyof T]: Arbitrary } = safeObjectCreate(null); + const poolConstraints = { + minLength: 1, + // Higher cycle frequency is achieved by using a smaller pool of objects, so we invert the input `frequency`. + size: invertSize(resolveSize(constraints.frequencySize)), + }; + for (const key in strictArbs) { + if (!safeHasOwnProperty(strictArbs, key)) { + // Prevents accidental iteration over properties inherited from an object’s prototype + continue; + } + const lazyAtKey: LazyArbitrary | undefined = lazyArbs[key]; + const lazyArb = lazyAtKey !== undefined ? lazyAtKey : new LazyArbitrary(key); + lazyArb.underlying = noShrink(nat().map((index) => ({ [placeholderSymbol]: { key, index } }))); + lazyArbs[key] = lazyArb; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + poolArbs[key] = array(strictArbs[key]!, poolConstraints); + } + + for (const key in strictArbs) { + if (!safeHasOwnProperty(strictArbs, key)) { + // Prevents accidental iteration over properties inherited from an object’s prototype + continue; + } + + const poolsArb = record(poolArbs as any) as Arbitrary<{ [K in keyof T]: unknown[] }>; + strictArbs[key] = poolsArb.map((pools) => { + derefPools(pools, placeholderSymbol); + return pools[key][0]; + }) as (typeof strictArbs)[typeof key]; + } + + return strictArbs; +} + /** * For mutually recursive types * @@ -72,7 +227,10 @@ export type LetrecLooselyTypedBuilder = (tie: LetrecLooselyTypedTie) => Letre * @remarks Since 1.16.0 * @public */ -export function letrec(builder: T extends Record ? LetrecTypedBuilder : never): LetrecValue; +export function letrec( + builder: T extends Record ? LetrecTypedBuilder : never, + constraints?: LetrecConstraints, +): LetrecValue; /** * For mutually recursive types * @@ -92,27 +250,12 @@ export function letrec(builder: T extends Record ? LetrecTyp * @remarks Since 1.16.0 * @public */ -export function letrec(builder: LetrecLooselyTypedBuilder): LetrecValue; -export function letrec(builder: LetrecLooselyTypedBuilder | LetrecTypedBuilder): LetrecValue { - const lazyArbs: { [K in keyof T]?: LazyArbitrary } = safeObjectCreate(null); - const tie = (key: keyof T): Arbitrary => { - if (!safeHasOwnProperty(lazyArbs, key)) { - // Call to hasOwnProperty ensures that the property key will be defined - lazyArbs[key] = new LazyArbitrary(String(key)); - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return lazyArbs[key]!; - }; - const strictArbs = builder(tie as any); - for (const key in strictArbs) { - if (!safeHasOwnProperty(strictArbs, key)) { - // Prevents accidental iteration over properties inherited from an object’s prototype - continue; - } - const lazyAtKey: LazyArbitrary | undefined = lazyArbs[key]; - const lazyArb = lazyAtKey !== undefined ? lazyAtKey : new LazyArbitrary(key); - lazyArb.underlying = strictArbs[key]; - lazyArbs[key] = lazyArb; - } - return strictArbs; +export function letrec(builder: LetrecLooselyTypedBuilder, constraints?: LetrecConstraints): LetrecValue; +export function letrec( + builder: LetrecLooselyTypedBuilder | LetrecTypedBuilder, + constraints: LetrecConstraints = {}, +): LetrecValue { + return constraints.withCycles + ? letrecWithCycles(builder, constraints.withCycles === true ? {} : constraints.withCycles) + : letrecWithoutCycles(builder); } diff --git a/packages/fast-check/test/unit/arbitrary/letrec.spec.ts b/packages/fast-check/test/unit/arbitrary/letrec.spec.ts index 59aa75cadb1..0455c3baf0f 100644 --- a/packages/fast-check/test/unit/arbitrary/letrec.spec.ts +++ b/packages/fast-check/test/unit/arbitrary/letrec.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { letrec } from '../../../src/arbitrary/letrec'; +import { derefPools, letrec } from '../../../src/arbitrary/letrec'; +import { record } from '../../../src/arbitrary/record'; import { LazyArbitrary } from '../../../src/arbitrary/_internals/LazyArbitrary'; import { Value } from '../../../src/check/arbitrary/definition/Value'; import { Stream } from '../../../src/stream/Stream'; @@ -7,6 +8,7 @@ import { FakeIntegerArbitrary, fakeArbitrary } from './__test-helpers__/Arbitrar import { fakeRandom } from './__test-helpers__/RandomHelpers'; import { assertGenerateEquivalentTo, + assertProduceCorrectValues, assertProduceSameValueGivenSameSeed, assertProduceValuesShrinkableWithoutContext, assertShrinkProducesSameValueWithoutInitialContext, @@ -14,7 +16,7 @@ import { describe('letrec', () => { describe('builder', () => { - it('should be able to construct independant arbitraries', () => { + it('should be able to construct independent arbitraries', () => { // Arrange const { instance: expectedArb1 } = fakeArbitrary(); const { instance: expectedArb2 } = fakeArbitrary(); @@ -30,6 +32,31 @@ describe('letrec', () => { expect(arb2).toBe(expectedArb2); }); + it('should be able to construct independent arbitraries with cycles', () => { + // Arrange + const expectedArb1 = new FakeIntegerArbitrary(1, 4); + const expectedArb2 = new FakeIntegerArbitrary(6, 4); + + // Act + const { arb1, arb2 } = letrec( + (_tie) => ({ + arb1: expectedArb1, + arb2: expectedArb2, + }), + { withCycles: true }, + ); + + // Assert + assertProduceCorrectValues( + () => arb1, + (value) => typeof value === 'number' && value >= 1 && value <= 5, + ); + assertProduceCorrectValues( + () => arb2, + (value) => typeof value === 'number' && value >= 6 && value <= 10, + ); + }); + it('should not produce LazyArbitrary for no-tie constructs', () => { // Arrange const { instance: expectedArb } = fakeArbitrary(); @@ -69,12 +96,15 @@ describe('letrec', () => { expect(arb).toBeInstanceOf(LazyArbitrary); }); - it('should be able to construct mutually recursive arbitraries', () => { + it.each([false, true])('should be able to construct mutually recursive arbitraries', (withCycles) => { // Arrange / Act - const { arb1, arb2 } = letrec((tie) => ({ - arb1: tie('arb2'), - arb2: tie('arb1'), - })); + const { arb1, arb2 } = letrec( + (tie) => ({ + arb1: tie('arb2'), + arb2: tie('arb1'), + }), + { withCycles }, + ); // Assert expect(arb1).toBeDefined(); @@ -101,6 +131,35 @@ describe('letrec', () => { expect(arb3).toBe(expectedArb); }); + it('should apply tie correctly with cycles', () => { + // Arrange + const expectedArb = new FakeIntegerArbitrary(1, 4); + + // Act + const { arb1, arb2, arb3 } = letrec( + (tie) => ({ + arb1: tie('arb2'), + arb2: tie('arb3'), + arb3: expectedArb, + }), + { withCycles: true }, + ); + + // Assert + assertProduceCorrectValues( + () => arb1, + (value) => typeof value === 'number' && value >= 1 && value <= 5, + ); + assertProduceCorrectValues( + () => arb2, + (value) => typeof value === 'number' && value >= 1 && value <= 5, + ); + assertProduceCorrectValues( + () => arb3, + (value) => typeof value === 'number' && value >= 1 && value <= 5, + ); + }); + it('should apply tie the same way for a reversed declaration', () => { // Arrange const { instance: expectedArb } = fakeArbitrary(); @@ -125,6 +184,38 @@ describe('letrec', () => { }); }); + it('should apply tie the same way for a reversed declaration with cycles', () => { + // Arrange + const expectedArb = new FakeIntegerArbitrary(1, 4); + + // Act + const { arb1, arb2, arb3 } = letrec( + (tie) => ({ + // Same scenario as 'should apply tie correctly' + // except we declared arb3 > arb2 > arb1 + // instead of arb1 > arb2 > arb3 + arb3: expectedArb, + arb2: tie('arb3'), + arb1: tie('arb2'), + }), + { withCycles: true }, + ); + + // Assert + assertProduceCorrectValues( + () => arb1, + (value) => typeof value === 'number' && value >= 1 && value <= 5, + ); + assertProduceCorrectValues( + () => arb2, + (value) => typeof value === 'number' && value >= 1 && value <= 5, + ); + assertProduceCorrectValues( + () => arb3, + (value) => typeof value === 'number' && value >= 1 && value <= 5, + ); + }); + describe('generate', () => { it('should be able to delay calls to tie to generate', () => { // Arrange @@ -150,38 +241,45 @@ describe('letrec', () => { expect(generate).toHaveBeenCalledWith(mrng, biasFactor); }); - it('should throw on generate if tie receives an invalid parameter', () => { + it.each([false, true])('should throw on generate if tie receives an invalid parameter', (withCycles) => { // Arrange const biasFactor = 42; - const { arb1 } = letrec((tie) => ({ - arb1: tie('missing'), - })); - const { instance: mrng } = fakeRandom(); - - // Act / Assert - expect(() => arb1.generate(mrng, biasFactor)).toThrowErrorMatchingInlineSnapshot( - `[Error: Lazy arbitrary "missing" not correctly initialized]`, + const { arb1 } = letrec( + (tie) => ({ + arb1: tie('missing'), + }), + { withCycles }, ); - }); - - it('should throw on generate if tie receives an invalid parameter after creation', () => { - // Arrange - const biasFactor = 42; - const { arb1 } = letrec((tie) => { - const { instance: simpleArb, generate } = fakeArbitrary(); - generate.mockImplementation((...args) => tie('missing').generate(...args)); - return { - arb1: simpleArb, - }; - }); const { instance: mrng } = fakeRandom(); // Act / Assert - expect(() => arb1.generate(mrng, biasFactor)).toThrowErrorMatchingInlineSnapshot( - `[Error: Lazy arbitrary "missing" not correctly initialized]`, - ); + expect(() => arb1.generate(mrng, biasFactor)).toThrowError('Lazy arbitrary "missing" not correctly initialized'); }); + it.each([false, true])( + 'should throw on generate if tie receives an invalid parameter after creation', + (withCycles) => { + // Arrange + const biasFactor = 42; + const { arb1 } = letrec( + (tie) => { + const { instance: simpleArb, generate } = fakeArbitrary(); + generate.mockImplementation((...args) => tie('missing').generate(...args)); + return { + arb1: simpleArb, + }; + }, + { withCycles }, + ); + const { instance: mrng } = fakeRandom(); + + // Act / Assert + expect(() => arb1.generate(mrng, biasFactor)).toThrowError( + 'Lazy arbitrary "missing" not correctly initialized', + ); + }, + ); + it('should accept "reserved" keys as output of builder', () => { // Arrange const biasFactor = 42; @@ -347,3 +445,116 @@ describe('letrec (integration)', () => { assertShrinkProducesSameValueWithoutInitialContext(letrecBuilder); }); }); + +describe('letrec with cycles (integration)', () => { + type Node = { + value: number; + next: Node; + }; + const letrecBuilder = () => { + const { node } = letrec<{ node: Node }>( + (tie) => ({ + node: record({ + value: new FakeIntegerArbitrary(), + next: tie('node'), + }), + }), + { withCycles: true }, + ); + return node; + }; + + it('should produce the same values given the same seed', () => { + assertProduceSameValueGivenSameSeed(letrecBuilder); + }); + + it('should only produce correct values', () => { + assertProduceCorrectValues(letrecBuilder, (node) => { + let circular = false; + const visited = new WeakSet(); + const assertNode = (node: Node) => { + if (visited.has(node)) { + circular = true; + return; + } + visited.add(node); + expect(typeof node.value).toBe('number'); + assertNode(node.next); + }; + assertNode(node); + // Must be circular because `next` isn't optional, so it has to circle + // around eventually. + expect(circular).toBe(true); + }); + }); +}); + +describe('derefPools', () => { + const placeholderSymbol = Symbol('placeholder'); + + it('pools without placeholders are not modified', () => { + const pools = { + a: [1, [2], 3], + b: [[4], 5, { x: 6 }], + c: [7, 8, { x: [{ y: 9 }] }], + }; + const poolsCopy = structuredClone(pools); + + derefPools(pools, placeholderSymbol); + + expect(pools).toStrictEqual(poolsCopy); + }); + + it('pools have placeholders replaced', () => { + const alreadyCircular: { x: unknown } = { x: null }; + alreadyCircular.x = alreadyCircular; + const pools = { + a: [ + 1, + // Mutual recursion. + [[[[{ x: { [placeholderSymbol]: { key: 'a', index: 2 } } }]]]], + { a: 'a', b: 'b', c: { [placeholderSymbol]: { key: 'a', index: 1 } } }, + ], + b: [ + // Direct recursion. + [[[[[['hello', { world: { [placeholderSymbol]: { key: 'b', index: 0 } } }]]]]]], + // Recursive placeholder replacement. + { value: { [placeholderSymbol]: { key: 'b', index: 2 } } }, + { [placeholderSymbol]: { key: 'b', index: 3 } }, + { [placeholderSymbol]: { key: 'b', index: 4 } }, + { value: 42 }, + // Cross pool mutual recursion. + { values: [{ [placeholderSymbol]: { key: 'c', index: 0 } }] }, + ], + c: [ + // Cross pool mutual recursion. + { value: { [placeholderSymbol]: { key: 'b', index: 5 } } }, + alreadyCircular, + ], + }; + + derefPools(pools, placeholderSymbol); + + // Mutual recursion. + const xObject: { x: unknown } = { x: null }; + const aValue1 = [[[[xObject]]]]; + const aValue2 = { a: 'a', b: 'b', c: aValue1 }; + xObject.x = aValue2; + // Direct recursion. + const worldObject: { world: unknown } = { world: null }; + const bValue0 = [[[[[['hello', worldObject]]]]]]; + worldObject.world = bValue0; + // Recursive placeholder replacement. + const value42 = { value: 42 }; + // Cross pool mutual recursion. + const bValue5 = { values: [] as unknown[] }; + const cValue0 = { value: bValue5 }; + bValue5.values.push(cValue0); + + expect(pools).toStrictEqual({ + a: [1, aValue1, aValue2], + b: [bValue0, { value: value42 }, value42, value42, value42, bValue5], + c: [cValue0, alreadyCircular], + }); + }); +});