jsproptest
Version:
Javascript Property-based Testing
279 lines (252 loc) • 11.8 kB
text/typescript
import { Shrinkable } from '../Shrinkable'
import { Stream } from '../Stream'
import { binarySearchShrinkable } from './integer'
/**
* Helper function to shrink elements within a specific chunk of the array.
* The array is conceptually divided into 2^power chunks, and this function
* processes the chunk specified by the offset.
*
* @internal
* @param ancestor - The original Shrinkable array structure.
* @param power - Determines the number of chunks (2^power).
* @param offset - Specifies which chunk to process (0 <= offset < 2^power).
* @returns A Stream of shrunken arrays, focusing on the specified chunk.
*/
function shrinkBulk<T>(
ancestor: Shrinkable<Array<Shrinkable<T>>>,
power: number,
offset: number
): Stream<Shrinkable<Array<Shrinkable<T>>>> {
const genStream = (
ancestor: Shrinkable<Array<Shrinkable<T>>>,
power: number,
offset: number,
parent: Shrinkable<Array<Shrinkable<T>>>,
fromPos: number,
toPos: number,
elemStreams: Array<Stream<Shrinkable<T>>>
): Stream<Shrinkable<Array<Shrinkable<T>>>> => {
const size = toPos - fromPos
if (size === 0) return Stream.empty()
if (elemStreams.length !== size) throw new Error(`element streams size error ${elemStreams.length} != ${size}`)
const newElemStreams: Array<Stream<Shrinkable<T>>> = []
const newArray = parent.value.concat()
const ancestorArray = ancestor.value
if (newArray.length !== ancestorArray.length)
throw new Error(`list size error: ${newArray.length} != ${ancestorArray.length}`)
let nothingToDo = true
for (let i = 0; i < elemStreams.length; i++) {
if (elemStreams[i].isEmpty()) {
newArray[i + fromPos] = new Shrinkable(ancestorArray[i + fromPos].value)
newElemStreams.push(Stream.empty())
} else {
newArray[i + fromPos] = elemStreams[i].getHead()
newElemStreams.push(elemStreams[i].getTail())
nothingToDo = false
}
}
if (nothingToDo) return Stream.empty()
let newShrinkable = new Shrinkable(newArray)
newShrinkable = newShrinkable.with(() => shrinkBulk(newShrinkable, power, offset))
return new Stream(newShrinkable, () =>
genStream(ancestor, power, offset, newShrinkable, fromPos, toPos, newElemStreams)
)
}
const parentSize = ancestor.value.length
const numSplits = Math.pow(2, power)
if (parentSize / numSplits < 1) return Stream.empty()
if (offset >= numSplits) throw new Error('offset should not reach numSplits')
const fromPos = (parentSize * offset) / numSplits
const toPos = (parentSize * (offset + 1)) / numSplits
if (toPos < parentSize) throw new Error(`topos error: ${toPos} != ${parentSize}`)
const parentArr = ancestor.value
const elemStreams: Array<Stream<Shrinkable<T>>> = []
let nothingToDo = true
for (let i = fromPos; i < toPos; i++) {
const shrinks = parentArr[i].shrinks()
elemStreams.push(shrinks)
if (!shrinks.isEmpty()) nothingToDo = false
}
if (nothingToDo) return Stream.empty()
return genStream(ancestor, power, offset, ancestor, fromPos, toPos, elemStreams)
}
/**
* Shrinks an array by shrinking its individual elements.
* This strategy divides the array into chunks (controlled by `power` and `offset`)
* and shrinks elements within the targeted chunk. It's useful for applying
* element-specific shrinking logic in a structured way.
*
* @param shrinkableElemsShr - The Shrinkable containing the array of Shrinkable elements.
* @param power - Determines the number of chunks (2^power) the array is divided into for shrinking.
* @param offset - Specifies which chunk (0 <= offset < 2^power) of elements to shrink in this step.
* @returns A Stream of Shrinkable arrays, where elements in the specified chunk have been shrunk.
*/
export function shrinkElementWise<T>(
shrinkableElemsShr: Shrinkable<Array<Shrinkable<T>>>,
power: number,
offset: number
): Stream<Shrinkable<Array<Shrinkable<T>>>> {
if (shrinkableElemsShr.value.length === 0) return Stream.empty()
const shrinkableElems = shrinkableElemsShr.value
const length = shrinkableElems.length
const numSplits = Math.pow(2, power)
if (length / numSplits < 1 || offset >= numSplits) return Stream.empty()
const newShrinkableElemsShr = shrinkableElemsShr.concat(parent => {
const length = parent.value.length
if (length / numSplits < 1 || offset >= numSplits) return Stream.empty()
else return shrinkBulk<T>(parent, power, offset)
})
return newShrinkableElemsShr.shrinks()
}
/**
* Shrinks an array by reducing its length from the rear.
* It attempts to produce arrays with lengths ranging from the original size down to `minSize`.
* Uses binary search internally for efficiency.
*
* @param shrinkableElems - The array of Shrinkable elements.
* @param minSize - The minimum allowed size for the shrunken array.
* @returns A Shrinkable representing arrays of potentially smaller lengths.
*/
// shrink array by removing elements at rear
export function shrinkArrayLength<T>(shrinkableElems: Shrinkable<T>[], minSize: number): Shrinkable<Shrinkable<T>[]> {
const size = shrinkableElems.length
const rangeShrinkable = binarySearchShrinkable(size - minSize).map(s => s + minSize)
return rangeShrinkable.map(newSize => {
if (newSize === 0) return []
else return shrinkableElems.slice(0, newSize)
})
}
/**
* Implements a shrinking strategy prioritizing removal from the front, then recursively
* shrinking the middle part by increasing the number of fixed elements at the rear.
*
* Strategy:
* 1. Shrink by removing elements from the front (`slice(0, frontSize)`), keeping `rearSize` elements fixed at the end.
* 2. Recursively:
* a. If further shrinking is possible, call `shrinkFrontAndThenMid` again with an increased `rearSize`.
* This fixes more elements at the rear and allows further shrinking of the front/middle.
* b. If front shrinking is exhausted but the array can still be shrunk towards `minSize`,
* delegate to `shrinkMid` to try removing elements from the middle/rear while keeping the front fixed.
*
* @internal
*/
function shrinkFrontAndThenMid<T>(
shrinkableElems: Shrinkable<T>[],
minSize: number,
rearSize: number
): Shrinkable<Shrinkable<T>[]> {
// remove front, fixing rear
const minFrontSize = Math.max(minSize - rearSize, 0) // rear size already occupies some elements
const maxFrontSize = shrinkableElems.length - rearSize
// front size within [min,max]
const rangeShrinkable = binarySearchShrinkable(maxFrontSize - minFrontSize).map(s => s + minFrontSize)
return rangeShrinkable
.map(frontSize => {
// concat front and rear
return shrinkableElems
.slice(0, frontSize)
.concat(shrinkableElems.slice(maxFrontSize, shrinkableElems.length))
})
.concat(parent => {
// increase rear size
const parentSize = parent.value.length
// no further shrinking possible
if (parentSize <= minSize || parentSize <= rearSize) {
if (minSize < parentSize && rearSize + 1 < parentSize)
return shrinkMid(parent.value, minSize, 1, rearSize + 1).shrinks()
else return Stream.empty()
}
// shrink front further by fixing last element in front to rear
// [[1,2,3,4]]
// [[1,2,3],[4]] → [[]|[1]|[1,2]|, [4]]
return shrinkFrontAndThenMid(parent.value, minSize, rearSize + 1).shrinks()
})
}
/**
* Implements a shrinking strategy focusing on removing elements from the middle/rear,
* keeping a fixed number of elements at the front.
*
* Strategy:
* 1. Shrink by removing elements from the middle/rear (`slice(shrinkableElems.length - rearSize, ...)`),
* keeping `frontSize` elements fixed at the beginning.
* 2. Recursively call `shrinkMid` with an increased `frontSize` to fix more elements
* at the front and allow further shrinking of the remaining middle/rear part.
*
* @internal
*/
function shrinkMid<T>(
shrinkableElems: Shrinkable<T>[],
minSize: number,
frontSize: number,
rearSize: number
): Shrinkable<Shrinkable<T>[]> {
const minRearSize = Math.max(minSize - frontSize, 0)
const maxRearSize = shrinkableElems.length - frontSize
// rear size within [min,max]
const rangeShrinkable = binarySearchShrinkable(maxRearSize - minRearSize).map(s => s + minRearSize)
return rangeShrinkable
.map(rearSize => {
// concat front and rear
return shrinkableElems
.slice(0, frontSize)
.concat(shrinkableElems.slice(shrinkableElems.length - rearSize, shrinkableElems.length))
})
.concat(parent => {
const parentSize = parent.value.length
// no further shrinking possible
if (parentSize <= minSize || parentSize <= frontSize) return Stream.empty()
// shrink rear further by fixing last element in rear to front
// [[1,2,3,4]]
// [[1],[2,3,4]] → [1,[]|[2]|[2,3]]
return shrinkMid(parent.value, minSize, frontSize + 1, rearSize).shrinks()
})
}
/**
* Shrinks an array by removing elements (membership).
* It primarily uses the `shrinkFrontAndThenMid` strategy, starting with
* no fixed elements at the rear (`rearSize = 0`), effectively prioritizing
* removal of elements from the front initially.
*
* @param shrinkableElems - The array of Shrinkable elements.
* @param minSize - The minimum allowed size for the shrunken array.
* @returns A Shrinkable representing arrays with potentially fewer elements.
*/
export function shrinkMembershipWise<T>(
shrinkableElems: Shrinkable<T>[],
minSize: number
): Shrinkable<Shrinkable<T>[]> {
return shrinkFrontAndThenMid(shrinkableElems, minSize, 0)
}
/**
* Creates a Shrinkable for an array, allowing shrinking by removing elements
* and optionally by shrinking the elements themselves.
*
* @param shrinkableElems - The initial array of Shrinkable elements.
* @param minSize - The minimum allowed length of the array after shrinking element membership.
* @param membershipWise - If true, allows shrinking by removing elements (membership). Defaults to true.
* @param elementWise - If true, applies element-wise shrinking *after* membership shrinking. Defaults to false.
* @returns A Shrinkable<Array<T>> that represents the original array and its potential shrunken versions.
*/
export function shrinkableArray<T>(
shrinkableElems: Array<Shrinkable<T>>,
minSize: number,
membershipWise = true,
elementWise = false
): Shrinkable<Array<T>> {
// Base Shrinkable containing the initial structure Shrinkable<T>[]
let currentShrinkable: Shrinkable<Shrinkable<T>[]> = new Shrinkable(shrinkableElems);
// Chain membership shrinking if enabled
if (membershipWise) {
currentShrinkable = currentShrinkable.andThen(parent => {
return shrinkMembershipWise(parent.value, minSize).shrinks();
});
}
// Chain element-wise shrinking if enabled
if (elementWise) {
currentShrinkable = currentShrinkable.andThen(parent => {
return shrinkElementWise(parent, 0, 0);
});
}
// Map the final Shrinkable<Shrinkable<T>[]> to Shrinkable<Array<T>> by extracting the values.
return currentShrinkable.map(theArr => theArr.map(shr => shr.value))
}