Skip to content

Commit

Permalink
Mutually recursive arbitrary builder (#377)
Browse files Browse the repository at this point in the history
* First draft implementation of letrec for fast-check

* Add support for bias for letrec

* Better typings for the tie function

* Add unit tests for letrec

* Add e2e tests for letrec

* Do not prettify dist/

* Add test to check recursive shrinker

* Add jsdoc

* Delayed calls to tie should be considered valid

* Check tie throws on generate when receiving invalid inputs

* Remove unnecessary check in withBias

* Check bias applies at most up to depth 5

* Add no regression tests for letrec

* Add letrec documentation
  • Loading branch information
dubzzz committed Jun 20, 2019
1 parent 02829ce commit 0740fcf
Show file tree
Hide file tree
Showing 9 changed files with 349 additions and 0 deletions.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
lib/
lib-*/
coverage/
dist/
docs/
*.generated.ts
*.generated.spec.ts
16 changes: 16 additions & 0 deletions documentation/1-Guides/Arbitraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,22 @@ Default for `values` are: `fc.boolean()`, `fc.integer()`, `fc.double()`, `fc.str
- `fc.jsonObject()` or `fc.jsonObject(maxDepth: number)` generate an object that is eligible to be stringified and parsed back to itself (object compatible with json stringify)
- `fc.unicodeJsonObject()` or `fc.unicodeJsonObject(maxDepth: number)` generate an object with potentially unicode characters that is eligible to be stringified and parsed back to itself (object compatible with json stringify)

## Recursive structures

- `fc.letrec(builder: (tie) => { [arbitraryName: string]: Arbitrary<T> })` produce arbitraries as specified by builder function. The `tie` function given to builder should be used as a placeholder to handle the recursion. It takes as input the name of the arbitrary to use in the recursion.

```typescript
const { tree } = fc.letrec(tie => ({
// tree is 1 / 3 of node, 2 / 3 of leaf
// Warning: if the probability of nodes equals or is greater
// than the one of leafs we might generate infinite trees
tree: fc.oneof(tie('node'), tie('leaf'), tie('leaf')),
node: fc.tuple(tie('tree'), tie('tree')),
leaf: fc.nat()
}));
tree() // Is a tree arbitrary (as fc.nat() is an integer arbitrary)
```

## Functions

- `compareBooleanFunc()` generate a comparison function taking two parameters `a` and `b` and producing a boolean value. `true` means that `a < b`, `false` that `a = b` or `a > b`
Expand Down
74 changes: 74 additions & 0 deletions src/check/arbitrary/LetRecArbitrary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Random } from '../../random/generator/Random';
import { Arbitrary } from './definition/Arbitrary';
import { Shrinkable } from './definition/Shrinkable';

/** @hidden */
export class LazyArbitrary extends Arbitrary<any> {
private static readonly MaxBiasLevels = 5;
private numBiasLevels = 0;
underlying: Arbitrary<any> | null = null;

constructor(readonly name: string) {
super();
}
generate(mrng: Random): Shrinkable<any> {
if (!this.underlying) {
throw new Error(`Lazy arbitrary ${JSON.stringify(this.name)} not correctly initialized`);
}
return this.underlying.generate(mrng);
}
withBias(freq: number): Arbitrary<any> {
if (!this.underlying) {
throw new Error(`Lazy arbitrary ${JSON.stringify(this.name)} not correctly initialized`);
}
if (this.numBiasLevels >= LazyArbitrary.MaxBiasLevels) {
return this;
}
++this.numBiasLevels;
const biasedArb = this.underlying.withBias(freq);
--this.numBiasLevels;
return biasedArb;
}
}

/** @hidden */
function isLazyArbitrary(arb: Arbitrary<any> | undefined): arb is LazyArbitrary {
return arb !== undefined && arb.hasOwnProperty('underlying');
}

/**
* For mutually recursive types
*
* @example
* ```typescript
* const { tree } = fc.letrec(tie => ({
* tree: fc.oneof(tie('node'), tie('leaf'), tie('leaf')),
* node: fc.tuple(tie('tree'), tie('tree')),
* leaf: fc.nat()
* })); // tree is 1 / 3 of node, 2 / 3 of leaf
* ```
*
* @param builder Arbitraries builder based on themselves (through `tie`)
*/
export function letrec<T>(
builder: (tie: (key: string) => Arbitrary<unknown>) => { [K in keyof T]: Arbitrary<T[K]> }
): { [K in keyof T]: Arbitrary<T[K]> } {
const lazyArbs: { [K in keyof T]?: Arbitrary<T[K]> } = {};
const tie = (key: keyof T): Arbitrary<any> => {
if (!lazyArbs[key]) lazyArbs[key] = new LazyArbitrary(key as any);
return lazyArbs[key]!;
};
const strictArbs = builder(tie as any);
for (const key in strictArbs) {
if (!strictArbs.hasOwnProperty(key)) {
// Prevents accidental iteration over properties inherited from an object’s prototype
continue;
}

const lazyAtKey = lazyArbs[key];
const lazyArb = isLazyArbitrary(lazyAtKey) ? lazyAtKey : new LazyArbitrary(key);
lazyArb.underlying = strictArbs[key];
lazyArbs[key] = lazyArb;
}
return strictArbs;
}
2 changes: 2 additions & 0 deletions src/fast-check-default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { compareBooleanFunc, compareFunc, func } from './check/arbitrary/Functio
import { domain } from './check/arbitrary/HostArbitrary';
import { integer, maxSafeInteger, maxSafeNat, nat } from './check/arbitrary/IntegerArbitrary';
import { ipV4, ipV6 } from './check/arbitrary/IpArbitrary';
import { letrec } from './check/arbitrary/LetRecArbitrary';
import { lorem } from './check/arbitrary/LoremArbitrary';
import { mapToConstant } from './check/arbitrary/MapToConstantArbitrary';
import {
Expand Down Expand Up @@ -146,6 +147,7 @@ export {
jsonObject,
unicodeJson,
unicodeJsonObject,
letrec,
compareBooleanFunc,
compareFunc,
func,
Expand Down
16 changes: 16 additions & 0 deletions test/e2e/NoRegression.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,22 @@ describe(`NoRegression`, () => {
it('emailAddress', () => {
expect(() => fc.assert(fc.property(fc.emailAddress(), v => testFunc(v)), settings)).toThrowErrorMatchingSnapshot();
});
it('letrec', () => {
expect(() =>
fc.assert(
fc.property(
fc.letrec(tie => ({
// Trick to be able to shrink from node to leaf
tree: fc.nat(1).chain(id => (id === 0 ? tie('leaf') : tie('node'))),
node: fc.record({ left: tie('tree'), right: tie('tree') }),
leaf: fc.nat(21)
})).tree,
v => testFunc(v)
),
settings
)
).toThrowErrorMatchingSnapshot();
});
it('commands', () => {
expect(() =>
fc.assert(
Expand Down
22 changes: 22 additions & 0 deletions test/e2e/__snapshots__/NoRegression.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,28 @@ Execution summary:
. . . . × [{\\"\\":null}]"
`;
exports[`NoRegression letrec 1`] = `
"Property failed after 6 tests
{ seed: 42, path: \\"5:1:0:1:0\\", endOnFailure: true }
Counterexample: [{\\"left\\":0,\\"right\\":{\\"left\\":0,\\"right\\":0}}]
Shrunk 4 time(s)
Got error: Property failed by returning false
Execution summary:
√ [16]
√ [1]
√ [{\\"left\\":16,\\"right\\":2}]
√ [8]
√ [19]
× [{\\"left\\":{\\"left\\":10,\\"right\\":{\\"left\\":17,\\"right\\":2}},\\"right\\":{\\"left\\":8,\\"right\\":2}}]
. √ [3]
. × [{\\"left\\":11,\\"right\\":{\\"left\\":8,\\"right\\":2}}]
. . × [{\\"left\\":0,\\"right\\":{\\"left\\":8,\\"right\\":2}}]
. . . √ [{\\"left\\":0,\\"right\\":13}]
. . . × [{\\"left\\":0,\\"right\\":{\\"left\\":0,\\"right\\":2}}]
. . . . × [{\\"left\\":0,\\"right\\":{\\"left\\":0,\\"right\\":0}}]"
`;
exports[`NoRegression lorem 1`] = `
"Property failed after 1 tests
{ seed: 42, path: \\"0:0\\", endOnFailure: true }
Expand Down
48 changes: 48 additions & 0 deletions test/e2e/arbitraries/LetRecArbitrary.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as fc from '../../../src/fast-check';

const seed = Date.now();
describe(`LetRecArbitrary (seed: ${seed})`, () => {
describe('letrec', () => {
it('Should be able to rebuild simple arbitraries', () => {
const ref = fc.array(fc.tuple(fc.string(), fc.integer()));
const { b } = fc.letrec(tie => ({
a: fc.integer(),
b: fc.array(tie('c')),
c: fc.tuple(tie('d'), tie('a')),
d: fc.string()
}));
expect(fc.sample(b, { seed })).toEqual(fc.sample(ref, { seed }));
});
it('Should be usable to build deep tree instances', () => {
const { tree } = fc.letrec(tie => ({
// tree is 1 / 3 of node, 2 / 3 of leaf
tree: fc.oneof(tie('node'), tie('leaf'), tie('leaf')),
node: fc.tuple(tie('tree'), tie('tree')),
leaf: fc.nat()
}));
const out = fc.check(
fc.property(tree, t => {
const depth = (n: any): number => {
if (typeof n === 'number') return 0;
return 1 + Math.max(depth(n[0]), depth(n[1]));
};
return depth(t) < 5;
}),
{ seed }
);
expect(out.failed).toBe(true); // depth can be greater or equal to 5
});
it('Should be able to shrink to smaller cases recursively', () => {
const { tree } = fc.letrec(tie => {
return {
tree: fc.nat(1).chain(id => (id === 0 ? tie('leaf') : tie('node'))),
node: fc.tuple(tie('tree'), tie('tree')),
leaf: fc.nat()
};
});
const out = fc.check(fc.property(tree, t => typeof t !== 'object'), { seed });
expect(out.failed).toBe(true);
expect(out.counterexample![0]).toEqual([0, 0]);
});
});
});
161 changes: 161 additions & 0 deletions test/unit/check/arbitrary/LetRecArbitrary.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { LazyArbitrary, letrec } from '../../../../src/check/arbitrary/LetRecArbitrary';
import { Arbitrary } from '../../../../src/check/arbitrary/definition/Arbitrary';
import { Shrinkable } from '../../../../src/check/arbitrary/definition/Shrinkable';
import { Random } from '../../../../src/random/generator/Random';

import * as stubRng from '../../stubs/generators';

describe('LetRecArbitrary', () => {
describe('letrec', () => {
it('Should be able to construct independant arbitraries', () => {
const expectedArb1 = buildArbitrary(jest.fn());
const expectedArb2 = buildArbitrary(jest.fn());

const { arb1, arb2 } = letrec(tie => ({
arb1: expectedArb1,
arb2: expectedArb2
}));

expect(arb1).toBe(expectedArb1);
expect(arb2).toBe(expectedArb2);
});
it('Should not produce LazyArbitrary for no-tie constructs', () => {
const { arb } = letrec(tie => ({
arb: buildArbitrary(jest.fn())
}));
expect(arb).not.toBeInstanceOf(LazyArbitrary);
});
it('Should not produce LazyArbitrary for indirect tie constructs', () => {
const { arb } = letrec(tie => ({
// arb is an arbitrary wrapping the tie value (as fc.array)
arb: buildArbitrary(mrng => tie('arb').generate(mrng))
}));
expect(arb).not.toBeInstanceOf(LazyArbitrary);
});
it('Should produce LazyArbitrary for direct tie constructs', () => {
const { arb } = letrec(tie => ({
arb: tie('arb')
}));
expect(arb).toBeInstanceOf(LazyArbitrary);
});
it('Should be able to construct mutually recursive arbitraries', () => {
const { arb1, arb2 } = letrec(tie => ({
arb1: tie('arb2'),
arb2: tie('arb1')
}));
expect(arb1).toBeDefined();
expect(arb2).toBeDefined();
});
it('Should apply tie correctly', () => {
const expectedArb = buildArbitrary(jest.fn());
const { arb1, arb2, arb3 } = letrec(tie => ({
arb1: tie('arb2'),
arb2: tie('arb3'),
arb3: expectedArb
}));

expect(arb1).toBeInstanceOf(LazyArbitrary);
expect(arb2).toBeInstanceOf(LazyArbitrary);
expect(arb3).not.toBeInstanceOf(LazyArbitrary);

expect((arb1 as any).underlying).toBe(arb2);
expect((arb2 as any).underlying).toBe(arb3);
expect(arb3).toBe(expectedArb);
});
it('Should be able to delay calls to tie', () => {
const mrng = stubRng.mutable.nocall();
const generateMock = jest.fn();
const simpleArb = buildArbitrary(generateMock);
const { arb1 } = letrec(tie => ({
arb1: buildArbitrary(mrng => tie('arb2').generate(mrng)),
arb2: simpleArb
}));

expect(generateMock).not.toHaveBeenCalled();
arb1.generate(mrng);

expect(generateMock).toHaveBeenCalled();
});
it('Should throw on generate if tie receives an invalid parameter', () => {
const mrng = stubRng.mutable.nocall();
const { arb1 } = letrec(tie => ({
arb1: tie('missing')
}));
expect(() => arb1.generate(mrng)).toThrowErrorMatchingSnapshot();
});
it('Should throw on generate if tie receives an invalid parameter after creation', () => {
const mrng = stubRng.mutable.nocall();
const { arb1 } = letrec(tie => ({
arb1: buildArbitrary(mrng => tie('missing').generate(mrng))
}));
expect(() => arb1.generate(mrng)).toThrowErrorMatchingSnapshot();
});
});
describe('LazyArbitrary', () => {
it('Should fail to generate when no underlying arbitrary', () => {
const mrng = stubRng.mutable.nocall();
const lazy = new LazyArbitrary('id007');
expect(() => lazy.generate(mrng)).toThrowErrorMatchingSnapshot();
});
it('Should fail to bias when no underlying arbitrary', () => {
const lazy = new LazyArbitrary('id008');
expect(() => lazy.withBias(2)).toThrowErrorMatchingSnapshot();
});
it('Should call generate method of underlying on generate', () => {
const mrng = stubRng.mutable.nocall();
const lazy = new LazyArbitrary('id008');
const expectedGen = Symbol();
const generateMock = jest.fn();
generateMock.mockReturnValue(expectedGen);
lazy.underlying = buildArbitrary(generateMock);

const g = lazy.generate(mrng);
expect(g).toBe(expectedGen);
expect(generateMock).toHaveBeenCalledTimes(1);
});
it('Should be able to bias to the biased value of underlying', () => {
const lazy = new LazyArbitrary('id008');
const noCallMock = jest.fn();
const biasedArb = buildArbitrary(noCallMock);
const arb = buildArbitrary(noCallMock, () => biasedArb);
lazy.underlying = arb;

const biasedLazy = lazy.withBias(2);
expect(biasedLazy).toBe(biasedArb);
expect(noCallMock).not.toHaveBeenCalled();
});
it('Should be able to bias recursive arbitraries', () => {
const lazy = new LazyArbitrary('id008');
const noCallMock = jest.fn();
lazy.underlying = buildArbitrary(noCallMock, n => lazy.withBias(n));

const biasedLazy = lazy.withBias(2);
expect(biasedLazy).toBe(lazy);
expect(noCallMock).not.toHaveBeenCalled();
});
it('Should bias recursive arbitraries at a max depth of 5', () => {
const lazyA = new LazyArbitrary('id007');
const lazyB = new LazyArbitrary('id008');

const noCallMock = jest.fn();
const biasAMock = jest.fn();
const biasBMock = jest.fn();
biasAMock.mockImplementation(n => lazyB.withBias(n));
biasBMock.mockImplementation(n => lazyB.withBias(n));
lazyA.underlying = buildArbitrary(noCallMock, biasAMock);
lazyB.underlying = buildArbitrary(noCallMock, biasBMock);

const biasedLazyA = lazyA.withBias(2);
expect(biasedLazyA).toBe(lazyB);
expect(biasAMock).toHaveBeenCalledTimes(1);
expect(biasBMock).toHaveBeenCalledTimes(5);
});
});
});

const buildArbitrary = (generate: (mrng: Random) => Shrinkable<any>, withBias?: (n: number) => Arbitrary<any>) => {
return new (class extends Arbitrary<any> {
generate = generate;
withBias = (n: number): Arbitrary<any> => (withBias ? withBias(n) : this);
})();
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`LetRecArbitrary LazyArbitrary Should fail to bias when no underlying arbitrary 1`] = `"Lazy arbitrary \\"id008\\" not correctly initialized"`;

exports[`LetRecArbitrary LazyArbitrary Should fail to generate when no underlying arbitrary 1`] = `"Lazy arbitrary \\"id007\\" not correctly initialized"`;

exports[`LetRecArbitrary letrec Should throw on generate if tie receives an invalid parameter 1`] = `"Lazy arbitrary \\"missing\\" not correctly initialized"`;

exports[`LetRecArbitrary letrec Should throw on generate if tie receives an invalid parameter after creation 1`] = `"Lazy arbitrary \\"missing\\" not correctly initialized"`;

0 comments on commit 0740fcf

Please sign in to comment.