jsproptest
Version:
Javascript Property-based Testing
421 lines (388 loc) • 19.5 kB
text/typescript
import { interval } from './generator/integer'
import { Random } from './Random'
import { Shrinkable } from './Shrinkable'
import { shrinkArrayLength } from './shrinker/array'
import { Stream } from './Stream'
/**
* Defines the core interface for generating random values along with their shrinkable counterparts.
* Shrinkable values are essential for property-based testing, allowing the system to find the smallest failing example.
* @template T The type of value to generate.
*/
export interface Generator<T> {
/**
* Generates a random value wrapped in a Shrinkable container.
* @param rand The random number generator instance.
* @returns A Shrinkable containing the generated value and its potential smaller versions.
*/
generate(rand: Random): Shrinkable<T>
/**
* Transforms the generated values using a provided function.
* @template U The type of the transformed value.
* @param transformer A function to apply to the generated value.
* @returns A new Generator producing transformed values.
*/
map<U>(transformer: (arg: T) => U): Generator<U>
/**
* Chains the generation process by using the output of this generator to create a new generator.
* This is useful for creating dependent random values.
* @template U The type produced by the subsequent generator.
* @param genFactory A function that takes the generated value and returns a new Generator.
* @returns A new Generator producing values from the chained generator.
*/
flatMap<U>(genFactory: (arg: T) => Generator<U>): Generator<U>
/**
* Similar to flatMap, but preserves the original value, returning a tuple.
* @template U The type produced by the subsequent generator.
* @param genFactory A function that takes the generated value and returns a new Generator.
* @returns A new Generator producing tuples of [originalValue, newValue].
*/
chain<U>(genFactory: (arg: T) => Generator<U>): Generator<[T, U]>
/**
* Chains generation assuming the current generator produces tuples, appending the new value.
* Requires the current generator to produce an array/tuple.
* @template Ts The tuple type produced by the current generator.
* @template U The type produced by the subsequent generator.
* @param genFactory A function that takes the generated tuple and returns a new Generator.
* @returns A new Generator producing tuples with the new value appended.
*/
chainAsTuple<Ts extends unknown[], U>(genFactory: (arg: Ts) => Generator<U>): Generator<[...Ts, U]>
/**
* Repeatedly applies a generator factory to the last generated value a specified number of times.
* The final value is returned.
* @param genFactory A function that takes the last value and returns the next generator.
* @param minSize The minimum number of aggregation steps.
* @param maxSize The maximum number of aggregation steps.
* @returns A new Generator producing the aggregated value.
*/
aggregate(genFactory: (arg: T) => Generator<T>, minSize: number, maxSize: number): Generator<T>
/**
* Similar to aggregate, but collects all intermediate values into an array.
* @param genFactory A function that takes the last value and returns the next generator.
* @param minSize The minimum number of accumulation steps.
* @param maxSize The maximum number of accumulation steps.
* @returns A new Generator producing an array of accumulated values.
*/
accumulate(genFactory: (arg: T) => Generator<T>, minSize: number, maxSize: number): Generator<Array<T>>
/**
* Filters the generated values based on a predicate.
* It will keep generating values until one satisfies the predicate.
* @param filterer A function that returns true if the value should be kept.
* @returns A new Generator producing only values that satisfy the predicate.
*/
filter(filterer: (value: T) => boolean): Generator<T>
}
/**
* A concrete implementation of the Generator interface.
* @template T The type of value to generate.
*/
export class Arbitrary<T> implements Generator<T> {
/**
* Creates an instance of Arbitrary.
* @param genFunction The core function used to generate Shrinkable values.
*/
constructor(readonly genFunction: GenFunction<T>) {}
generate(rand: Random): Shrinkable<T> {
return this.genFunction(rand)
}
map<U>(transformer: (arg: T) => U): Generator<U> {
// Creates a new Arbitrary that applies the transformer to the generated Shrinkable's value.
return new Arbitrary<U>((rand: Random) => this.generate(rand).map(transformer))
}
aggregate(genFactory: (arg: T) => Generator<T>, minSize: number, maxSize: number): Generator<T> {
// Uses an integer generator to determine the number of aggregation steps.
return interval(minSize, maxSize).flatMap(
size =>
new Arbitrary<T>((rand: Random) => {
// Start with the initial value.
let shr = this.generate(rand)
// Apply the factory repeatedly.
for (let i = 1; i < size; i++) shr = genFactory(shr.value).generate(rand)
return shr
})
)
}
accumulate(genFactory: (arg: T) => Generator<T>, minSize: number, maxSize: number): Generator<Array<T>> {
return new Arbitrary<Array<T>>((rand: Random) => {
const size = rand.interval(minSize, maxSize)
if (size === 0) return new Shrinkable([]) // Handle empty accumulation.
let shr = this.generate(rand)
const shrArr = [shr] // Store all generated shrinkables.
for (let i = 1; i < size; i++) {
shr = genFactory(shr.value).generate(rand)
shrArr.push(shr)
}
// Shrink the array primarily by length, then by shrinking the last element.
// This is a common strategy for shrinking collections.
return shrinkArrayLength(shrArr, minSize)
.andThen(parent => {
const shrArr = parent.value
if (shrArr.length === 0) return new Stream()
const lastElemShr = shrArr[shrArr.length - 1]
const elemShrinks = lastElemShr.shrinks()
if (elemShrinks.isEmpty()) return new Stream()
// Create shrinks by replacing the last element with its own shrinks.
return elemShrinks.transform(elem => {
const copy = shrArr.concat()
copy[copy.length - 1] = elem
return new Shrinkable(copy)
})
})
.map(arr => arr.map(shr => shr.value)) // Extract values from Shrinkables.
})
}
flatMap<U>(genFactory: (arg: T) => Generator<U>): Generator<U> {
return new Arbitrary<U>((rand: Random) => {
// Generate the initial value and map it to the *next* generator's Shrinkable.
const intermediate: Shrinkable<Shrinkable<U>> = this
.generate(rand)
.map(value => genFactory(value).generate(rand))
// Shrinking involves two steps:
// 1. Shrink the intermediate Shrinkable (which shrinks the *outer* value T).
// 2. Shrink the inner Shrinkable (which shrinks the *inner* value U).
return intermediate
.andThen(interShr =>
// This flatMap combines shrinks from the inner Shrinkable<U>.
interShr.value.flatMap<Shrinkable<U>>(second => new Shrinkable(new Shrinkable(second))).shrinks()
)
.map(shr => shr.value) // Extract the final U value.
})
}
chain<U>(genFactory: (arg: T) => Generator<U>): Generator<[T, U]> {
return new Arbitrary<[T, U]>((rand: Random) => {
// Similar to flatMap, but keeps the original value T.
const intermediate: Shrinkable<[T, Shrinkable<U>]> = this
.generate(rand)
.map(value => [value, genFactory(value).generate(rand)]) // Keep 'value' (type T).
// Shrinking:
// 1. Shrink the intermediate Shrinkable (shrinks T).
// 2. Shrink the inner Shrinkable (shrinks U).
return intermediate
.andThen(interShr =>
// Combine shrinks from the inner Shrinkable<U>.
interShr.value[1]
.flatMap<[T, Shrinkable<U>]>(
second => new Shrinkable([interShr.value[0], new Shrinkable(second)]) // Reconstruct the pair.
)
.shrinks()
)
.map(pair => [pair[0], pair[1].value]) // Extract the final U value from the inner Shrinkable.
})
}
chainAsTuple<Ts extends unknown[], U>(genFactory: (arg: Ts) => Generator<U>): Generator<[...Ts, U]> {
return new Arbitrary<[...Ts, U]>((rand: Random) => {
const intermediate: Shrinkable<[...Ts, Shrinkable<U>]> = this.generate(rand).map(value => {
// Expects the current generator to produce an array/tuple.
if (!Array.isArray(value)) throw new Error('method unsupported for the type')
const tuple = (value as unknown) as Ts
// Append the new Shrinkable<U> to the tuple.
return [...tuple, genFactory(tuple).generate(rand)]
})
// Shrinking logic similar to chain, adapted for tuples.
return intermediate
.andThen(interShr => {
const head = interShr.value.slice(0, interShr.value.length - 1) as Ts
const tail = interShr.value[interShr.value.length - 1] as Shrinkable<U>
// Combine shrinks from the tail element (Shrinkable<U>).
return tail
.flatMap<[...Ts, Shrinkable<U>]>(second => new Shrinkable([...head, new Shrinkable(second)]))
.shrinks()
})
.map(pair => [
...(pair.slice(0, pair.length - 1) as Ts),
// Extract the final U value from the last element.
(pair[pair.length - 1] as Shrinkable<U>).value,
])
})
}
filter(filterer: (value: T) => boolean): Generator<T> {
return new Arbitrary<T>((rand: Random) => {
// Keep generating until a value satisfies the filter.
// Note: This can potentially loop infinitely if the filter is too restrictive.
while (true) {
const shr = this.generate(rand)
if (filterer(shr.value)) return shr.filter(filterer) // Apply filter to shrinks as well.
}
})
}
}
/**
* A Generator implementation specifically designed for container-like structures (e.g., arrays, strings),
* incorporating default size constraints.
* @template T The type of value the container holds or the container type itself.
*/
export class ArbiContainer<T> implements Generator<T> {
/** Default minimum size for generated containers. */
public static defaultMinSize: number = 0
/** Default maximum size for generated containers. */
public static defaultMaxSize: number = 100
/**
* Creates an instance of ArbiContainer.
* @param genFunction The core function used to generate Shrinkable values.
* @param minSize Minimum size constraint for the generated container.
* @param maxSize Maximum size constraint for the generated container.
*/
constructor(
readonly genFunction: GenFunction<T>,
public minSize: number = ArbiContainer.defaultMinSize,
public maxSize: number = ArbiContainer.defaultMaxSize
) {}
// Most methods delegate to the underlying genFunction but wrap the result
// in a new ArbiContainer to preserve the size constraints.
generate(rand: Random): Shrinkable<T> {
// Potentially use minSize/maxSize within the genFunction if needed,
// although the standard implementation doesn't directly enforce it here.
// Size enforcement often happens within specific container generator functions (like array, string).
return this.genFunction(rand)
}
map<U>(transformer: (arg: T) => U): Generator<U> {
// Preserve size constraints when mapping.
return new ArbiContainer<U>((rand: Random) => this.generate(rand).map(transformer), this.minSize, this.maxSize)
}
flatMap<U>(genFactory: (arg: T) => Generator<U>): Generator<U> {
// Preserve size constraints when flatMapping.
return new ArbiContainer<U>(
(rand: Random) => {
// Standard flatMap logic.
const intermediate: Shrinkable<Shrinkable<U>> = this
.generate(rand)
.map(value => genFactory(value).generate(rand))
return intermediate
.andThen(interShr =>
interShr.value
.flatMap<Shrinkable<U>>(second => new Shrinkable(new Shrinkable(second)))
.shrinks()
)
.map(pair => pair.value)
},
this.minSize,
this.maxSize
)
}
chain<U>(genFactory: (arg: T) => Generator<U>): Generator<[T, U]> {
// Preserve size constraints when chaining.
return new ArbiContainer<[T, U]>(
(rand: Random) => {
// Standard chain logic.
const intermediate: Shrinkable<[T, Shrinkable<U>]> = this
.generate(rand)
.map(value => [value, genFactory(value).generate(rand)])
return intermediate
.andThen(interShr =>
interShr.value[1]
.flatMap<[T, Shrinkable<U>]>(
second => new Shrinkable([interShr.value[0], new Shrinkable(second)])
)
.shrinks()
)
.map(pair => [pair[0], pair[1].value])
},
this.minSize,
this.maxSize
)
}
// Note: chainAsTuple in ArbiContainer currently returns an Arbitrary, not preserving ArbiContainer properties.
// This might be intentional or an oversight depending on desired behavior.
chainAsTuple<Ts extends unknown[], U>(genFactory: (arg: Ts) => Generator<U>): Generator<[...Ts, U]> {
return new Arbitrary<[...Ts, U]>((rand: Random) => {
const intermediate: Shrinkable<[...Ts, Shrinkable<U>]> = this.generate(rand).map(value => {
if (!Array.isArray(value)) throw new Error('method unsupported for the type')
const tuple = (value as unknown) as Ts
return [...tuple, genFactory(tuple).generate(rand)]
})
return intermediate
.andThen(interShr => {
const head = interShr.value.slice(0, interShr.value.length - 1) as Ts
const tail = interShr.value[interShr.value.length - 1] as Shrinkable<U>
return tail
.flatMap<[...Ts, Shrinkable<U>]>(second => new Shrinkable([...head, new Shrinkable(second)]))
.shrinks()
})
.map(pair => [
...(pair.slice(0, pair.length - 1) as Ts),
(pair[pair.length - 1] as Shrinkable<U>).value,
])
})
}
aggregate(genFactory: (arg: T) => Generator<T>, minSize: number, maxSize: number): Generator<T> {
// Preserve size constraints for the resulting generator.
return interval(minSize, maxSize).flatMap(
size =>
new ArbiContainer<T>(
(rand: Random) => {
// Standard aggregate logic.
let shr = this.generate(rand)
for (let i = 1; i < size; i++) shr = genFactory(shr.value).generate(rand)
return shr
},
minSize, // Use the aggregate's size for the new container constraints.
maxSize
)
)
}
accumulate(genFactory: (arg: T) => Generator<T>, minSize: number, maxSize: number): Generator<Array<T>> {
// Preserve size constraints for the resulting generator.
return new ArbiContainer<Array<T>>(
(rand: Random) => {
// Standard accumulate logic with shrinking.
const size = rand.interval(minSize, maxSize)
if (size === 0) return new Shrinkable([])
let shr = this.generate(rand)
const shrArr = [shr]
for (let i = 1; i < size; i++) {
shr = genFactory(shr.value).generate(rand)
shrArr.push(shr)
}
return shrinkArrayLength(shrArr, minSize)
.andThen(parent => {
const shrArr = parent.value
if (shrArr.length === 0) return new Stream()
const lastElemShr = shrArr[shrArr.length - 1]
const elemShrinks = lastElemShr.shrinks()
if (elemShrinks.isEmpty()) return new Stream()
return elemShrinks.transform(elem => {
const copy = shrArr.concat()
copy[copy.length - 1] = elem
return new Shrinkable(copy)
})
})
.map(arr => arr.map(shr => shr.value))
},
minSize, // Use the accumulate's size for the new container constraints.
maxSize
)
}
filter(filterer: (value: T) => boolean): Generator<T> {
// Preserve size constraints when filtering.
return new ArbiContainer<T>(
(rand: Random) => {
// Standard filter logic.
// Potential infinite loop risk remains.
while (true) {
const shr = this.generate(rand)
// Mistake in original code? Should filter the *returned* shrinkable, not regenerate.
// if (filterer(shr.value)) return this.generate(rand).filter(filterer)
// Corrected version:
if (filterer(shr.value)) return shr.filter(filterer)
}
},
this.minSize,
this.maxSize
)
}
/**
* Updates the minimum and maximum size constraints for this container generator instance.
* @param min The new minimum size.
* @param max The new maximum size.
*/
setSize(min: number, max: number) {
this.minSize = min
this.maxSize = max
}
}
/**
* Type alias for the core function within a Generator that produces a Shrinkable value.
* @template ARG The type of value to generate.
* @param rand The random number generator instance.
* @returns A Shrinkable value.
*/
export type GenFunction<ARG> = (rand: Random) => Shrinkable<ARG>