import { Stream } from './Stream' import { Try } from './Try' /** * Represents a value along with its potential shrinks. * In property-based testing, when a test fails, the Shrinkable is used * to find a simpler counterexample by recursively exploring the shrinks. * * @template T The type of the value being shrunk. */ export class Shrinkable { constructor( readonly value: T, readonly shrinksGen: () => Stream> = () => new Stream>() ) {} toString() { return `Shrinkable(${this.value})` } shrinks(): Stream> { return this.shrinksGen() } with(shrinksGen: () => Stream>): Shrinkable { return new Shrinkable(this.value, shrinksGen) } /** * Concatenates the given stream to the horizontal dead-ends of shrinkable tree. Does not alter this shrinkable. * Adds additional candidates to the tree with fixed stream. * @param then the stream to concatenate * @returns a new shrinkable with the concatenation of each stream in shrinkable tree and the given stream */ concatStatic(then: () => Stream>): Shrinkable { return this.with(() => this.shrinks() .transform(shr => shr.concatStatic(then)) .concat(then()) ) } /** * Concatenates the stream generated with given stream generator to the horizontal dead-ends of shrinkable tree. Does not alter this shrinkable. * Adds additional candidates to the tree, represented as stream generated based on the parent shrinkable of the horizontal dead-end. * @param then the stream generator to generate stream for concatenation. the function takes parent shrinkable as input. * @returns a new shrinkable with the concatenation of each stream in shrinkable tree and the given stream */ concat(then: (_: Shrinkable) => Stream>): Shrinkable { return this.with(() => this.shrinks() .transform(shr => shr.concat(then)) .concat(then(this)) ) } /** * Inserts the given stream to the vertical dead-ends of shrinkable tree. Does not alter this shrinkable. * @param then the stream to insert at the vertical dead-ends * @returns a new shrinkable with the insertion of the given stream at the vertical dead-ends */ andThenStatic(then: () => Stream>): Shrinkable { if (this.shrinks().isEmpty()) { return this.with(then) } else { return this.with(() => this.shrinks().transform(shr => shr.andThenStatic(then))) } } /** * Inserts the stream generated with given stream generator to the vertical dead-ends of shrinkable tree. Does not alter this shrinkable. * Adds additional candidates to the tree, represented as stream generated based on the parent shrinkable of the vertical dead-end. * This effectively appends new shrinking strategy to the shrinkable * @param then the stream generator to generate stream for insertion. the function takes parent shrinkable as input. * @returns a new shrinkable with the insertion of the given stream at the vertical dead-ends */ andThen(then: (_: Shrinkable) => Stream>): Shrinkable { if (this.shrinks().isEmpty()) { // filter: remove duplicates return this.with(() => then(this).filter(shr => shr.value !== this.value)) } else { return this.with(() => this.shrinks().transform(shr => shr.andThen(then))) } } map(transformer: (_: T) => U): Shrinkable { const shrinkable: Shrinkable = new Shrinkable(transformer(this.value), () => this.shrinksGen().transform(shr => shr.map(transformer)) ) return shrinkable } flatMap(transformer: (_: T) => Shrinkable): Shrinkable { return transformer(this.value).with(() => this.shrinks().transform(shr => shr.flatMap(transformer))) } /** @throws If `criteria(value)` is false. */ filter(criteria: (_: T) => boolean): Shrinkable { if (!criteria(this.value)) throw new Error('cannot apply criteria') return this.with(() => this.shrinksGen() .filter(shr => criteria(shr.value)) .transform(shr => shr.filter(criteria)) ) } take(n: number) { return this.with(() => this.shrinksGen().take(n)) } /** @throws If `n` is out of bounds. */ getNthChild(n: number): Shrinkable { if (n < 0) throw new Error('Shrinkable getNthChild failed: index out of bound: ' + n + ' < 0') const shrinks = this.shrinks() let i = 0 for (const iter = shrinks.iterator(); iter.hasNext(); i++) { if (i === n) return iter.next() else iter.next() } throw new Error('Shrinkable getNthChild failed: index out of bound: ' + n + ' >= ' + i) } /** @throws If any {@link getNthChild} step is out of range. */ retrieve(steps: number[]): Shrinkable { let shr: Shrinkable = this // eslint-disable-line @typescript-eslint/no-this-alias for (let i = 0; i < steps.length; i++) { shr = Try(() => shr.getNthChild(steps[i])).getOrThrow( e => new Error('Shrinkable retrieval failed at step ' + i + ': ' + e.toString() + ' for steps: ' + steps.join(',')) ) } return shr } }