diff --git a/src/check/arbitrary/ArrayArbitrary.ts b/src/check/arbitrary/ArrayArbitrary.ts index f0e54e40f23..fe245ecea2c 100644 --- a/src/check/arbitrary/ArrayArbitrary.ts +++ b/src/check/arbitrary/ArrayArbitrary.ts @@ -5,6 +5,7 @@ import { ArbitraryWithShrink } from './definition/ArbitraryWithShrink'; import { biasWrapper } from './definition/BiasedArbitraryWrapper'; import { Shrinkable } from './definition/Shrinkable'; import { integer } from './IntegerArbitrary'; +import { cloneMethod } from '../symbols'; /** @hidden */ class ArrayArbitrary extends Arbitrary { @@ -18,11 +19,30 @@ class ArrayArbitrary extends Arbitrary { super(); this.lengthArb = integer(minLength, maxLength); } + private static makeItCloneable(vs: T[], shrinkables: Shrinkable[]) { + (vs as any)[cloneMethod] = () => { + const cloned = []; + for (let idx = 0; idx !== shrinkables.length; ++idx) { + cloned.push(shrinkables[idx].value); // push potentially cloned values + } + this.makeItCloneable(cloned, shrinkables); + return cloned; + }; + return vs; + } private wrapper(itemsRaw: Shrinkable[], shrunkOnce: boolean): Shrinkable { const items = this.preFilter(itemsRaw); - return new Shrinkable(items.map(s => s.value), () => - this.shrinkImpl(items, shrunkOnce).map(v => this.wrapper(v, true)) - ); + let cloneable = false; + const vs = []; + for (let idx = 0; idx !== items.length; ++idx) { + const s = items[idx]; + cloneable = cloneable || s.hasToBeCloned; + vs.push(s.value); // TODO: it might be possible not to clone some values + } + if (cloneable) { + ArrayArbitrary.makeItCloneable(vs, items); + } + return new Shrinkable(vs, () => this.shrinkImpl(items, shrunkOnce).map(v => this.wrapper(v, true))); } generate(mrng: Random): Shrinkable { const size = this.lengthArb.generate(mrng); diff --git a/src/check/arbitrary/ContextArbitrary.ts b/src/check/arbitrary/ContextArbitrary.ts new file mode 100644 index 00000000000..82f34a50a0e --- /dev/null +++ b/src/check/arbitrary/ContextArbitrary.ts @@ -0,0 +1,44 @@ +import { cloneMethod } from '../symbols'; +import { constant } from './ConstantArbitrary'; +import { Arbitrary } from './definition/Arbitrary'; + +/** + * Interface for IContext instances + */ +export interface IContext { + /** + * Log execution details during a test. + * Very helpful when troubleshooting failures + * @param data Data to be logged into the current context + */ + log(data: string): void; + /** + * Number of logs already logged into current context + */ + size(): number; +} + +/** @hidden */ +class ContextImplem implements IContext { + private readonly receivedLogs: string[]; + constructor() { + this.receivedLogs = []; + } + log(data: string): void { + this.receivedLogs.push(data); + } + size(): number { + return this.receivedLogs.length; + } + toString() { + return JSON.stringify({ logs: this.receivedLogs }); + } + [cloneMethod]() { + return new ContextImplem(); + } +} + +/** + * Produce a {@link IContext} instance + */ +export const context = () => constant(new ContextImplem()) as Arbitrary; diff --git a/src/check/arbitrary/OptionArbitrary.ts b/src/check/arbitrary/OptionArbitrary.ts index cc76ca4c4ac..95e21984549 100644 --- a/src/check/arbitrary/OptionArbitrary.ts +++ b/src/check/arbitrary/OptionArbitrary.ts @@ -14,7 +14,7 @@ class OptionArbitrary extends Arbitrary { function* g(): IterableIterator> { yield new Shrinkable(null); } - return new Shrinkable(s.value, () => + return new Shrinkable(s.value_, () => s .shrink() .map(OptionArbitrary.extendedShrinkable) diff --git a/src/check/arbitrary/SetArbitrary.ts b/src/check/arbitrary/SetArbitrary.ts index 93aef0d2819..fa0fb1dac6c 100644 --- a/src/check/arbitrary/SetArbitrary.ts +++ b/src/check/arbitrary/SetArbitrary.ts @@ -22,7 +22,7 @@ function buildCompareFilter(compare: (a: T, b: T) => boolean): ((tab: Shrinka return (tab: Shrinkable[]): Shrinkable[] => { let finalLength = tab.length; for (let idx = tab.length - 1; idx !== -1; --idx) { - if (subArrayContains(tab, idx, t => compare(t.value, tab[idx].value))) { + if (subArrayContains(tab, idx, t => compare(t.value_, tab[idx].value_))) { --finalLength; swap(tab, idx, finalLength); } diff --git a/src/check/arbitrary/TupleArbitrary.generic.ts b/src/check/arbitrary/TupleArbitrary.generic.ts index 1fea064e607..2cebd0efd88 100644 --- a/src/check/arbitrary/TupleArbitrary.generic.ts +++ b/src/check/arbitrary/TupleArbitrary.generic.ts @@ -2,6 +2,7 @@ import { Random } from '../../random/generator/Random'; import { Stream } from '../../stream/Stream'; import { Arbitrary } from './definition/Arbitrary'; import { Shrinkable } from './definition/Shrinkable'; +import { cloneMethod } from '../symbols'; /** @hidden */ class GenericTupleArbitrary extends Arbitrary { @@ -13,10 +14,29 @@ class GenericTupleArbitrary extends Arbitrary { throw new Error(`Invalid parameter encountered at index ${idx}: expecting an Arbitrary`); } } + private static makeItCloneable(vs: Ts[], shrinkables: Shrinkable[]) { + (vs as any)[cloneMethod] = () => { + const cloned = []; + for (let idx = 0; idx !== shrinkables.length; ++idx) { + cloned.push(shrinkables[idx].value); // push potentially cloned values + } + GenericTupleArbitrary.makeItCloneable(cloned, shrinkables); + return cloned; + }; + return vs; + } private static wrapper(shrinkables: Shrinkable[]): Shrinkable { - return new Shrinkable(shrinkables.map(s => s.value), () => - GenericTupleArbitrary.shrinkImpl(shrinkables).map(GenericTupleArbitrary.wrapper) - ); + let cloneable = false; + const vs = []; + for (let idx = 0; idx !== shrinkables.length; ++idx) { + const s = shrinkables[idx]; + cloneable = cloneable || s.hasToBeCloned; + vs.push(s.value); // TODO: it might be possible not to clone some values + } + if (cloneable) { + GenericTupleArbitrary.makeItCloneable(vs, shrinkables); + } + return new Shrinkable(vs, () => GenericTupleArbitrary.shrinkImpl(shrinkables).map(GenericTupleArbitrary.wrapper)); } generate(mrng: Random): Shrinkable { return GenericTupleArbitrary.wrapper(this.arbs.map(a => a.generate(mrng))); diff --git a/src/check/arbitrary/definition/Shrinkable.ts b/src/check/arbitrary/definition/Shrinkable.ts index fe41cf23bc1..716d1e34930 100644 --- a/src/check/arbitrary/definition/Shrinkable.ts +++ b/src/check/arbitrary/definition/Shrinkable.ts @@ -1,15 +1,38 @@ import { Stream } from '../../../stream/Stream'; +import { hasCloneMethod, WithCloneMethod, cloneMethod } from '../../symbols'; /** * A Shrinkable holds an internal value of type `T` * and can shrink it to smaller `T` values */ export class Shrinkable { + /** + * State storing the result of hasCloneMethod + * If the value will be cloned each time it gets accessed + */ + readonly hasToBeCloned: boolean; + /** + * Safe value of the shrinkable + * Depending on {@link hasToBeCloned} it will either be {@link value_} or a clone of it + */ + readonly value: T; + /** * @param value Internal value of the shrinkable * @param shrink Function producing Stream of shrinks associated to value */ - constructor(readonly value: T, readonly shrink: () => Stream> = () => Stream.nil>()) {} + constructor(readonly value_: T, readonly shrink: () => Stream> = () => Stream.nil>()) { + this.hasToBeCloned = hasCloneMethod(value_); + Object.defineProperty(this, 'value', { get: this.getValue }); + } + + /** @hidden */ + private getValue() { + if (this.hasToBeCloned) { + return ((this.value_ as unknown) as WithCloneMethod)[cloneMethod](); + } + return this.value_; + } /** * Create another shrinkable by mapping all values using the provided `mapper` diff --git a/src/check/runner/Runner.ts b/src/check/runner/Runner.ts index d6cf42c25a8..92955c068e4 100644 --- a/src/check/runner/Runner.ts +++ b/src/check/runner/Runner.ts @@ -36,9 +36,9 @@ function runIt( done = true; let idx = 0; for (const v of values) { - const out = property.run(v.value) as PreconditionFailure | string | null; + const out = property.run(v.value_) as PreconditionFailure | string | null; if (out != null && typeof out === 'string') { - runExecution.fail(v.value, idx, out); + runExecution.fail(v.value_, idx, out); values = v.shrink(); done = false; break; @@ -79,9 +79,9 @@ async function asyncRunIt( done = true; let idx = 0; for (const v of values) { - const out = await property.run(v.value); + const out = await property.run(v.value_); if (out != null && typeof out === 'string') { - runExecution.fail(v.value, idx, out); + runExecution.fail(v.value_, idx, out); values = v.shrink(); done = false; break; diff --git a/src/check/symbols.ts b/src/check/symbols.ts new file mode 100644 index 00000000000..f3253bdad62 --- /dev/null +++ b/src/check/symbols.ts @@ -0,0 +1,25 @@ +/** + * Generated instances having a method [cloneMethod] + * will be automatically cloned whenever necessary + * + * This is pretty useful for statefull generated values. + * For instance, whenever you use a Stream you directly impact it. + * Implementing [cloneMethod] on the generated Stream would force + * the framework to clone it whenever it has to re-use it + * (mainly required for chrinking process) + */ +export const cloneMethod = Symbol.for('fast-check/cloneMethod'); + +/** @hidden */ +export interface WithCloneMethod { + [cloneMethod]: () => T; +} + +/** @hidden */ +export const hasCloneMethod = (instance: T | WithCloneMethod): instance is WithCloneMethod => { + // Valid values for `instanceof Object`: + // [], {}, () => {}, function() {}, async () => {}, async function() {} + // Invalid ones: + // 1, "", Symbol(), null, undefined + return instance instanceof Object && typeof (instance as any)[cloneMethod] === 'function'; +}; diff --git a/src/fast-check-default.ts b/src/fast-check-default.ts index 605c4d71b17..53f5b70f8e8 100644 --- a/src/fast-check-default.ts +++ b/src/fast-check-default.ts @@ -10,6 +10,7 @@ import { array } from './check/arbitrary/ArrayArbitrary'; import { boolean } from './check/arbitrary/BooleanArbitrary'; import { ascii, base64, char, char16bits, fullUnicode, hexa, unicode } from './check/arbitrary/CharacterArbitrary'; import { constant, constantFrom } from './check/arbitrary/ConstantArbitrary'; +import { context, IContext } from './check/arbitrary/ContextArbitrary'; import { Arbitrary } from './check/arbitrary/definition/Arbitrary'; import { Shrinkable } from './check/arbitrary/definition/Shrinkable'; import { dictionary } from './check/arbitrary/DictionaryArbitrary'; @@ -53,6 +54,7 @@ import { asyncModelRun, modelRun } from './check/model/ModelRunner'; import { Random } from './random/generator/Random'; import { Stream, stream } from './stream/Stream'; +import { cloneMethod } from './check/symbols'; // boolean // floating point types @@ -117,6 +119,7 @@ export { compareBooleanFunc, compareFunc, func, + context, // model-based AsyncCommand, Command, @@ -127,7 +130,9 @@ export { // extend the framework Arbitrary, Shrinkable, + cloneMethod, // interfaces + IContext, ObjectConstraints, Parameters, RecordConstraints, diff --git a/test/e2e/StateFullArbitraries.spec.ts b/test/e2e/StateFullArbitraries.spec.ts new file mode 100644 index 00000000000..31e58d94bc3 --- /dev/null +++ b/test/e2e/StateFullArbitraries.spec.ts @@ -0,0 +1,139 @@ +import * as assert from 'assert'; +import * as fc from '../../src/fast-check'; + +const seed = Date.now(); +describe(`StateFullArbitraries (seed: ${seed})`, () => { + describe('Never call with non-cloned cloneable instance', () => { + it('normal property', () => { + let nonClonedDetected = false; + const status = fc.check( + fc.property(fc.integer(), fc.context(), fc.integer(), (a, ctx, b) => { + nonClonedDetected = nonClonedDetected || ctx.size() !== 0; + ctx.log('logging stuff'); + return a < b; + }), {seed} + ); + assert.ok(status.failed); + assert.ok(!nonClonedDetected); + }); + it('fc.oneof', () => { + let nonClonedDetected = false; + const status = fc.check( + fc.property(fc.integer(), fc.oneof(fc.context()), fc.integer(), (a, ctx, b) => { + nonClonedDetected = nonClonedDetected || ctx.size() !== 0; + ctx.log('logging stuff'); + return a < b; + }), {seed} + ); + assert.ok(status.failed); + assert.ok(!nonClonedDetected); + }); + it('fc.frequency', () => { + let nonClonedDetected = false; + const status = fc.check( + fc.property(fc.integer(), fc.frequency({ weight: 1, arbitrary: fc.context() }), fc.integer(), (a, ctx, b) => { + nonClonedDetected = nonClonedDetected || ctx.size() !== 0; + ctx.log('logging stuff'); + return a < b; + }), {seed} + ); + assert.ok(status.failed); + assert.ok(!nonClonedDetected); + }); + it('fc.option', () => { + let nonClonedDetected = false; + const status = fc.check( + fc.property(fc.integer(), fc.option(fc.context()), fc.integer(), (a, ctx, b) => { + if (ctx != null) { + nonClonedDetected = nonClonedDetected || ctx.size() !== 0; + ctx.log('logging stuff'); + } + return ctx != null && a < b; + }), {seed} + ); + assert.ok(status.failed); + assert.ok(!nonClonedDetected); + }); + it('fc.tuple', () => { + let nonClonedDetected = false; + const status = fc.check( + fc.property(fc.integer(), fc.tuple(fc.nat(), fc.context(), fc.nat()), fc.integer(), (a, [_a, ctx, _b], b) => { + nonClonedDetected = nonClonedDetected || ctx.size() !== 0; + ctx.log('logging stuff'); + return a < b; + }), {seed} + ); + assert.ok(status.failed); + assert.ok(!nonClonedDetected); + }); + it('fc.tuple (multiple cloneables)', () => { + let nonClonedDetected = false; + const status = fc.check( + fc.property(fc.integer(), fc.tuple(fc.context(), fc.context(), fc.context()), fc.integer(), (a, ctxs, b) => { + for (const ctx of ctxs) { + nonClonedDetected = nonClonedDetected || ctx.size() !== 0; + ctx.log('logging stuff'); + } + return a < b; + }), {seed} + ); + assert.ok(status.failed); + assert.ok(!nonClonedDetected); + }); + it('fc.array', () => { + let nonClonedDetected = false; + const status = fc.check( + fc.property(fc.integer(), fc.array(fc.context()), fc.integer(), (a, ctxs, b) => { + for (const ctx of ctxs) { + nonClonedDetected = nonClonedDetected || ctx.size() !== 0; + ctx.log('logging stuff'); + } + return a < b; + }), {seed} + ); + assert.ok(status.failed); + assert.ok(!nonClonedDetected); + }); + it('fc.set', () => { + let nonClonedDetected = false; + const status = fc.check( + fc.property(fc.integer(), fc.set(fc.context()), fc.integer(), (a, ctxs, b) => { + for (const ctx of ctxs) { + nonClonedDetected = nonClonedDetected || ctx.size() !== 0; + ctx.log('logging stuff'); + } + return a < b; + }), {seed} + ); + assert.ok(status.failed); + assert.ok(!nonClonedDetected); + }); + xit('fc.record', () => { + let nonClonedDetected = false; + const status = fc.check( + fc.property(fc.integer(), fc.record({ ctx: fc.context() }), fc.integer(), (a, { ctx }, b) => { + nonClonedDetected = nonClonedDetected || ctx.size() !== 0; + ctx.log('logging stuff'); + return a < b; + }), {seed} + ); + assert.ok(status.failed); + assert.ok(!nonClonedDetected); + }); + }); + xit('fc.dictionary', () => { + let nonClonedDetected = false; + const status = fc.check( + fc.property(fc.integer(), fc.dictionary(fc.string(), fc.context()), fc.integer(), (a, dict, b) => { + for (const k in dict) { + const ctx = dict[k]; + nonClonedDetected = nonClonedDetected || ctx.size() !== 0; + ctx.log('logging stuff'); + } + return a < b; + }), {seed} + ); + assert.ok(status.failed); + assert.ok(!nonClonedDetected); + }); +}); diff --git a/test/unit/check/arbitrary/ArrayArbitrary.spec.ts b/test/unit/check/arbitrary/ArrayArbitrary.spec.ts index f85f1742df0..6c4e8121cd5 100644 --- a/test/unit/check/arbitrary/ArrayArbitrary.spec.ts +++ b/test/unit/check/arbitrary/ArrayArbitrary.spec.ts @@ -4,12 +4,14 @@ import * as fc from '../../../../lib/fast-check'; import { Arbitrary } from '../../../../src/check/arbitrary/definition/Arbitrary'; import { Shrinkable } from '../../../../src/check/arbitrary/definition/Shrinkable'; import { array } from '../../../../src/check/arbitrary/ArrayArbitrary'; +import { context } from '../../../../src/check/arbitrary/ContextArbitrary'; import { nat } from '../../../../src/check/arbitrary/IntegerArbitrary'; import { Random } from '../../../../src/random/generator/Random'; import * as genericHelper from './generic/GenericArbitraryHelper'; import * as stubRng from '../../stubs/generators'; +import { hasCloneMethod } from '../../../../src/check/symbols'; class DummyArbitrary extends Arbitrary<{ key: number }> { constructor(public value: () => number) { @@ -54,6 +56,16 @@ describe('ArrayArbitrary', () => { return true; }) )); + it('Should produce cloneable array if one cloneable children', () => { + const mrng = stubRng.mutable.counter(0); + let g = array(context(), 1, 10).generate(mrng).value; + return hasCloneMethod(g); + }); + it('Should not produce cloneable tuple if no cloneable children', () => { + const mrng = stubRng.mutable.counter(0); + let g = array(nat(), 1, 10).generate(mrng).value; + return hasCloneMethod(g); + }); describe('Given no length constraints', () => { genericHelper.isValidArbitrary(() => array(nat()), { isStrictlySmallerValue: isStrictlySmallerArray, diff --git a/test/unit/check/arbitrary/ContextArbitrary.spec.ts b/test/unit/check/arbitrary/ContextArbitrary.spec.ts new file mode 100644 index 00000000000..2fa220b5859 --- /dev/null +++ b/test/unit/check/arbitrary/ContextArbitrary.spec.ts @@ -0,0 +1,37 @@ +import * as assert from 'assert'; + +import { context } from '../../../../src/check/arbitrary/ContextArbitrary'; + +import * as stubRng from '../../stubs/generators'; +import { hasCloneMethod, cloneMethod } from '../../../../src/check/symbols'; + +describe('ContextArbitrary', () => { + describe('context', () => { + it('Should generate a cloneable instance', () => { + const mrng = stubRng.mutable.nocall(); + const g = context().generate(mrng).value; + assert.ok(hasCloneMethod(g)); + }); + it('Should not reset its own logs on clone', () => { + const mrng = stubRng.mutable.nocall(); + const g = context().generate(mrng).value; + if (!hasCloneMethod(g)) throw new Error('context should be a cloneable instance'); + g.log('a'); + const gBeforeClone = String(g); + assert.ok(g[cloneMethod]() != null); + assert.equal(String(g), gBeforeClone); + assert.equal(g.size(), 1); + }); + it('Should produce a clone without any logs', () => { + const mrng = stubRng.mutable.nocall(); + const g = context().generate(mrng).value; + if (!hasCloneMethod(g)) throw new Error('context should be a cloneable instance'); + const gBeforeLogs = String(g); + g.log('a'); + const g2 = g[cloneMethod](); + assert.notEqual(String(g2), String(g)); + assert.equal(String(g2), gBeforeLogs); + assert.equal(g2.size(), 0); + }); + }); +}); diff --git a/test/unit/check/arbitrary/TupleArbitrary.generic.spec.ts b/test/unit/check/arbitrary/TupleArbitrary.generic.spec.ts index 1e097982b66..e21a1c6321d 100644 --- a/test/unit/check/arbitrary/TupleArbitrary.generic.spec.ts +++ b/test/unit/check/arbitrary/TupleArbitrary.generic.spec.ts @@ -3,10 +3,13 @@ import * as fc from '../../../../lib/fast-check'; import { dummy } from './TupleArbitrary.properties'; import { Arbitrary } from '../../../../src/check/arbitrary/definition/Arbitrary'; +import { context } from '../../../../src/check/arbitrary/ContextArbitrary'; import { integer } from '../../../../src/check/arbitrary/IntegerArbitrary'; import { genericTuple } from '../../../../src/check/arbitrary/TupleArbitrary'; import * as genericHelper from './generic/GenericArbitraryHelper'; +import * as stubRng from '../../stubs/generators'; +import { hasCloneMethod } from '../../../../src/check/symbols'; describe('TupleArbitrary', () => { describe('genericTuple', () => { @@ -28,5 +31,25 @@ describe('TupleArbitrary', () => { assert.throws(() => genericTuple([dummy(1), dummy(2), (null as any) as Arbitrary]))); it('Should throw on invalid arbitrary', () => assert.throws(() => genericTuple([dummy(1), dummy(2), >{}]))); + it('Should produce cloneable tuple if one cloneable children', () => + fc.assert( + fc.property(fc.nat(50), fc.nat(50), (before, after) => { + const arbsBefore = [...Array(before)].map(() => integer(0, 0)); + const arbsAfter = [...Array(before)].map(() => integer(0, 0)); + const arbs: Arbitrary[] = [...arbsBefore, context(), ...arbsAfter]; + const mrng = stubRng.mutable.counter(0); + const g = genericTuple(arbs).generate(mrng).value; + return hasCloneMethod(g); + }) + )); + it('Should not produce cloneable tuple if no cloneable children', () => + fc.assert( + fc.property(fc.nat(100), num => { + const arbs = [...Array(num)].map(() => integer(0, 0)); + const mrng = stubRng.mutable.counter(0); + const g = genericTuple(arbs).generate(mrng).value; + return !hasCloneMethod(g); + }) + )); }); }); diff --git a/test/unit/check/arbitrary/definition/Shrinkable.spec.ts b/test/unit/check/arbitrary/definition/Shrinkable.spec.ts new file mode 100644 index 00000000000..1554ea1ff3c --- /dev/null +++ b/test/unit/check/arbitrary/definition/Shrinkable.spec.ts @@ -0,0 +1,53 @@ +import * as assert from 'assert'; +import { Shrinkable } from '../../../../../src/check/arbitrary/definition/Shrinkable'; +import { cloneMethod } from '../../../../../src/check/symbols'; + +describe('Shrinkable', () => { + it('Should detect absence of [cloneMethod] method', () => { + const notCloneable = {}; + const s = new Shrinkable(notCloneable); + assert.ok(!s.hasToBeCloned); + }); + it('Should detect [cloneMethod] method', () => { + const cloneable = { [cloneMethod]: () => cloneable }; + const s = new Shrinkable(cloneable); + assert.ok(s.hasToBeCloned); + }); + it('Should not call [cloneMethod] on instantiation', () => { + let numCalls = 0; + const cloneable = { + [cloneMethod]: () => { + ++numCalls; + return cloneable; + } + }; + new Shrinkable(cloneable); + assert.equal(numCalls, 0); + }); + it('Should call [cloneMethod] on value accessor', () => { + let numCalls = 0; + const theClone = {}; + const cloneable = { + [cloneMethod]: () => { + ++numCalls; + return theClone; + } + }; + const s = new Shrinkable(cloneable); + assert.ok(s.value === theClone); + assert.equal(numCalls, 1); + }); + it('Should not call [cloneMethod] on value accessor', () => { + let numCalls = 0; + const theClone = {}; + const cloneable = { + [cloneMethod]: () => { + ++numCalls; + return theClone; + } + }; + const s = new Shrinkable(cloneable); + assert.ok(s.value_ === cloneable); + assert.equal(numCalls, 0); + }); +});