Skip to content

Commit

Permalink
Merge 835b8d1 into 32f4974
Browse files Browse the repository at this point in the history
  • Loading branch information
dubzzz committed Feb 3, 2019
2 parents 32f4974 + 835b8d1 commit 827f18e
Show file tree
Hide file tree
Showing 11 changed files with 325 additions and 19 deletions.
12 changes: 12 additions & 0 deletions documentation/Arbitraries.md
Expand Up @@ -169,7 +169,19 @@ While `fc.array` or any other array arbitrary could be used to generate such dat

Possible signatures:
- `fc.commands<Model, Real>(commandArbs: Arbitrary<Command<Model, Real>>[], maxCommands?: number)` arrays of `Command` that can be ingested by `fc.modelRun`
- `fc.commands<Model, Real>(commandArbs: Arbitrary<Command<Model, Real>>[], settings: CommandsSettings)` arrays of `Command` that can be ingested by `fc.modelRun`
- `fc.commands<Model, Real>(commandArbs: Arbitrary<AsyncCommand<Model, Real>>[], maxCommands?: number)` arrays of `AsyncCommand` that can be ingested by `fc.asyncModelRun`
- `fc.commands<Model, Real>(commandArbs: Arbitrary<AsyncCommand<Model, Real>>[], settings: CommandsSettings)` arrays of `AsyncCommand` that can be ingested by `fc.asyncModelRun`

Possible settings:
```typescript
interface CommandsSettings {
maxCommands?: number; // optional, maximal number of commands to generate per run: 10 by default
disableReplayLog?: boolean; // optional, do not show replayPath in the output: false by default
replayPath?: string; // optional, hint for replay purposes only: '' by default
// should be used in conjonction with {seed, path} of fc.assert
}
```

### Model runner

Expand Down
4 changes: 4 additions & 0 deletions documentation/Tips.md
Expand Up @@ -113,6 +113,8 @@ fc.assert(

The code above can easily be applied to other state machines, APIs or UI. In the case of asynchronous operations you need to implement `AsyncCommand` and use `asyncModelRun`.

**NOTE:** Contrary to other arbitraries, commands built using `fc.commands` requires an extra parameter for replay purposes. In addition of passing `{ seed, path }` to `fc.assert`, `fc.commands` must be called with `{ replayPath: string }`.

## Opt for verbose failures

By default, the failures reported by `fast-check` feature most relevant data:
Expand Down Expand Up @@ -262,6 +264,8 @@ fc.assert(
);
```

**NOTE:** Replaying `fc.commands` requires passing an additional flag called `replayPath` when building this arbitrary.

## Add custom examples next to generated ones

Sometimes it might be useful to run your test on some custom examples you think useful: either because they caused your code to fail in the past or because you explicitely want to confirm it succeeds on this specific example.
Expand Down
58 changes: 58 additions & 0 deletions src/check/model/ReplayPath.ts
@@ -0,0 +1,58 @@
/** @hidden */
interface Count {
value: boolean;
count: number;
}

/** @hidden */
export class ReplayPath {
static parse(replayPathStr: string): boolean[] {
const [serializedCount, serializedChanges] = replayPathStr.split(':');
const counts = serializedCount.split('').map(c => this.b64ToInt(c) + 1);
const changesInt = serializedChanges.split('').map(c => this.b64ToInt(c));
const changes: boolean[] = [];
for (let idx = 0; idx !== changesInt.length; ++idx) {
let current = changesInt[idx];
for (let n = 0; n !== 6; ++n, current >>= 1) {
changes.push(current % 2 === 1);
}
}

const replayPath: boolean[] = [];
for (let idx = 0; idx !== counts.length; ++idx) {
const count = counts[idx];
const value = changes[idx];
for (let num = 0; num !== count; ++num) replayPath.push(value);
}
return replayPath;
}
static stringify(replayPath: boolean[]): string {
const aggregatedPath = replayPath.reduce((counts: Count[], cur: boolean) => {
if (counts.length === 0 || counts[counts.length - 1].count === 64 || counts[counts.length - 1].value !== cur)
counts.push({ value: cur, count: 1 });
else counts[counts.length - 1].count += 1;
return counts;
}, []);
const serializedCount = aggregatedPath.map(({ count }) => this.intToB64(count - 1)).join('');
let serializedChanges = '';
for (let idx = 0; idx < aggregatedPath.length; idx += 6) {
const changesInt = aggregatedPath
.slice(idx, idx + 6)
.reduceRight((prev: number, cur: Count) => prev * 2 + (cur.value ? 1 : 0), 0);
serializedChanges += this.intToB64(changesInt);
}
return `${serializedCount}:${serializedChanges}`;
}
static intToB64(n: number): string {
if (n < 26) return String.fromCharCode(n + 65); // A-Z
if (n < 52) return String.fromCharCode(n + 97 - 26); // a-z
if (n < 62) return String.fromCharCode(n + 48 - 52); // 0-9
return String.fromCharCode(n === 62 ? 43 : 47); // +/
}
static b64ToInt(c: string): number {
if ('A' <= c && c <= 'Z') return c.charCodeAt(0) - 65;
if ('a' <= c && c <= 'z') return c.charCodeAt(0) - 97 + 26;
if ('0' <= c && c <= '9') return c.charCodeAt(0) - 48 + 52;
return c === '+' ? 62 : 63;
}
}
89 changes: 84 additions & 5 deletions src/check/model/commands/CommandsArbitrary.ts
Expand Up @@ -8,7 +8,9 @@ import { oneof } from '../../arbitrary/OneOfArbitrary';
import { AsyncCommand } from '../command/AsyncCommand';
import { Command } from '../command/Command';
import { ICommand } from '../command/ICommand';
import { ReplayPath } from '../ReplayPath';
import { CommandsIterable } from './CommandsIterable';
import { CommandsSettings } from './CommandsSettings';
import { CommandWrapper } from './CommandWrapper';

/** @hidden */
Expand All @@ -17,16 +19,28 @@ class CommandsArbitrary<Model extends object, Real, RunResult, CheckAsync extend
> {
readonly oneCommandArb: Arbitrary<CommandWrapper<Model, Real, RunResult, CheckAsync>>;
readonly lengthArb: ArbitraryWithShrink<number>;
constructor(commandArbs: Arbitrary<ICommand<Model, Real, RunResult, CheckAsync>>[], maxCommands: number) {
private replayPath: boolean[];
private replayPathPosition: number;
constructor(
commandArbs: Arbitrary<ICommand<Model, Real, RunResult, CheckAsync>>[],
maxCommands: number,
readonly sourceReplayPath: string | null,
readonly disableReplayLog: boolean
) {
super();
this.oneCommandArb = oneof(...commandArbs).map(c => new CommandWrapper(c));
this.lengthArb = nat(maxCommands);
this.replayPath = []; // updated at first shrink
this.replayPathPosition = 0;
}
private metadataForReplay() {
return this.disableReplayLog ? '' : `replayPath=${JSON.stringify(ReplayPath.stringify(this.replayPath))}`;
}
private wrapper(
items: Shrinkable<CommandWrapper<Model, Real, RunResult, CheckAsync>>[],
shrunkOnce: boolean
): Shrinkable<CommandsIterable<Model, Real, RunResult, CheckAsync>> {
return new Shrinkable(new CommandsIterable(items.map(s => s.value_)), () =>
return new Shrinkable(new CommandsIterable(items.map(s => s.value_), () => this.metadataForReplay()), () =>
this.shrinkImpl(items, shrunkOnce).map(v => this.wrapper(v, true))
);
}
Expand All @@ -37,13 +51,46 @@ class CommandsArbitrary<Model extends object, Real, RunResult, CheckAsync extend
const item = this.oneCommandArb.generate(mrng);
items[idx] = item;
}
this.replayPathPosition = 0; // reset replay
return this.wrapper(items, false);
}
/** Filter commands based on the real status of the execution */
private filterOnExecution(itemsRaw: Shrinkable<CommandWrapper<Model, Real, RunResult, CheckAsync>>[]) {
const items: typeof itemsRaw = [];
for (const c of itemsRaw) {
if (c.value_.hasRan) {
this.replayPath.push(true);
items.push(c);
} else this.replayPath.push(false);
}
return items;
}
/** Filter commands based on the internal replay state */
private filterOnReplay(itemsRaw: Shrinkable<CommandWrapper<Model, Real, RunResult, CheckAsync>>[]) {
return itemsRaw.filter((c, idx) => {
const state = this.replayPath[this.replayPathPosition + idx];
if (state === undefined) throw new Error(`Too short replayPath`);
if (!state && c.value_.hasRan) throw new Error(`Mismatch between replayPath and real execution`);
return state;
});
}
/** Filter commands for shrinking purposes */
private filterForShrinkImpl(itemsRaw: Shrinkable<CommandWrapper<Model, Real, RunResult, CheckAsync>>[]) {
if (this.replayPathPosition === 0) {
this.replayPath = this.sourceReplayPath !== null ? ReplayPath.parse(this.sourceReplayPath) : [];
}
const items =
this.replayPathPosition < this.replayPath.length
? this.filterOnReplay(itemsRaw)
: this.filterOnExecution(itemsRaw);
this.replayPathPosition += itemsRaw.length;
return items;
}
private shrinkImpl(
itemsRaw: Shrinkable<CommandWrapper<Model, Real, RunResult, CheckAsync>>[],
shrunkOnce: boolean
): Stream<Shrinkable<CommandWrapper<Model, Real, RunResult, CheckAsync>>[]> {
const items = itemsRaw.filter(c => c.value_.hasRan); // filter out commands that have not been executed
const items = this.filterForShrinkImpl(itemsRaw); // filter out commands that have not been executed
if (items.length === 0) {
return Stream.nil<Shrinkable<CommandWrapper<Model, Real, RunResult, CheckAsync>>[]>();
}
Expand Down Expand Up @@ -105,11 +152,43 @@ function commands<Model extends object, Real>(
commandArbs: Arbitrary<Command<Model, Real>>[],
maxCommands?: number
): Arbitrary<Iterable<Command<Model, Real>>>;
/**
* For arrays of {@link AsyncCommand} to be executed by {@link asyncModelRun}
*
* This implementation comes with a shrinker adapted for commands.
* It should shrink more efficiently than {@link array} for {@link AsyncCommand} arrays.
*
* @param commandArbs Arbitraries responsible to build commands
* @param maxCommands Maximal number of commands to build
*/
function commands<Model extends object, Real, CheckAsync extends boolean>(
commandArbs: Arbitrary<AsyncCommand<Model, Real, CheckAsync>>[],
settings?: CommandsSettings
): Arbitrary<Iterable<AsyncCommand<Model, Real, CheckAsync>>>;
/**
* For arrays of {@link Command} to be executed by {@link modelRun}
*
* This implementation comes with a shrinker adapted for commands.
* It should shrink more efficiently than {@link array} for {@link Command} arrays.
*
* @param commandArbs Arbitraries responsible to build commands
* @param maxCommands Maximal number of commands to build
*/
function commands<Model extends object, Real>(
commandArbs: Arbitrary<Command<Model, Real>>[],
settings?: CommandsSettings
): Arbitrary<Iterable<Command<Model, Real>>>;
function commands<Model extends object, Real, RunResult, CheckAsync extends boolean>(
commandArbs: Arbitrary<ICommand<Model, Real, RunResult, CheckAsync>>[],
maxCommands?: number
settings?: number | CommandsSettings
): Arbitrary<Iterable<ICommand<Model, Real, RunResult, CheckAsync>>> {
return new CommandsArbitrary(commandArbs, maxCommands != null ? maxCommands : 10);
const config = settings == null ? {} : typeof settings === 'number' ? { maxCommands: settings } : settings;
return new CommandsArbitrary(
commandArbs,
config.maxCommands != null ? config.maxCommands : 10,
config.replayPath != null ? config.replayPath : null,
!!config.disableReplayLog
);
}

export { commands };
11 changes: 8 additions & 3 deletions src/check/model/commands/CommandsIterable.ts
Expand Up @@ -4,17 +4,22 @@ import { CommandWrapper } from './CommandWrapper';
/** @hidden */
export class CommandsIterable<Model extends object, Real, RunResult, CheckAsync extends boolean = false>
implements Iterable<CommandWrapper<Model, Real, RunResult, CheckAsync>> {
constructor(readonly commands: CommandWrapper<Model, Real, RunResult, CheckAsync>[]) {}
constructor(
readonly commands: CommandWrapper<Model, Real, RunResult, CheckAsync>[],
readonly metadataForReplay: () => string
) {}
[Symbol.iterator](): Iterator<CommandWrapper<Model, Real, RunResult, CheckAsync>> {
return this.commands[Symbol.iterator]();
}
[cloneMethod]() {
return new CommandsIterable(this.commands.map(c => c.clone()));
return new CommandsIterable(this.commands.map(c => c.clone()), this.metadataForReplay);
}
toString(): string {
return this.commands
const serializedCommands = this.commands
.filter(c => c.hasRan)
.map(c => c.toString())
.join(',');
const metadata = this.metadataForReplay();
return metadata.length !== 0 ? `${serializedCommands} /*${metadata}*/` : serializedCommands;
}
}
19 changes: 19 additions & 0 deletions src/check/model/commands/CommandsSettings.ts
@@ -0,0 +1,19 @@
/**
* Parameters for fc.commands
*/
export interface CommandsSettings {
/**
* Maximal number of commands to generate per run
*/
maxCommands?: number;
/**
* Do not show replayPath in the output
*/
disableReplayLog?: boolean;
/**
* Hint for replay purposes only
*
* Should be used in conjonction with { seed, path } of fc.assert
*/
replayPath?: string;
}
50 changes: 50 additions & 0 deletions test/e2e/ReplayCommands.spec.ts
@@ -0,0 +1,50 @@
import * as fc from '../../src/fast-check';

// Fake commands
type Model = { counter: number };
type Real = {};
class IncBy implements fc.Command<Model, Real> {
constructor(readonly v: number) {}
check = (m: Readonly<Model>) => true;
run = (m: Model, r: Real) => (m.counter += this.v);
toString = () => `IncBy(${this.v})`;
}
class DecPosBy implements fc.Command<Model, Real> {
constructor(readonly v: number) {}
check = (m: Readonly<Model>) => m.counter > 0;
run = (m: Model, r: Real) => (m.counter -= this.v);
toString = () => `DecPosBy(${this.v})`;
}
class AlwaysPos implements fc.Command<Model, Real> {
check = (m: Readonly<Model>) => true;
run = (m: Model, r: Real) => {
if (m.counter < 0) throw new Error('counter is supposed to be always greater or equal to zero');
};
toString = () => `AlwaysPos()`;
}

const seed = Date.now();
describe(`ReplayCommands (seed: ${seed})`, () => {
it('Should be able to replay commands by specifying replayPath in fc.commands', () => {
const buildProp = (replayPath?: string) => {
return fc.property(
fc.commands(
[fc.nat().map(v => new IncBy(v)), fc.nat().map(v => new DecPosBy(v)), fc.constant(new AlwaysPos())],
{ replayPath }
),
cmds => fc.modelRun(() => ({ model: { counter: 0 }, real: {} }), cmds)
);
};

const out = fc.check(buildProp(), { seed: seed });
expect(out.failed).toBe(true);

const path = out.counterexamplePath!;
const replayPath = /\/\*replayPath=['"](.*)['"]\*\//.exec(out.counterexample![0].toString())![1];

const outReplayed = fc.check(buildProp(replayPath), { seed, path });
expect(outReplayed.counterexamplePath).toEqual(out.counterexamplePath);
expect(outReplayed.counterexample![0].toString()).toEqual(out.counterexample![0].toString());
expect(outReplayed.numRuns).toEqual(1);
});
});
10 changes: 7 additions & 3 deletions test/e2e/model/CommandsArbitrary.spec.ts
Expand Up @@ -76,7 +76,7 @@ describe(`CommandsArbitrary (seed: ${seed})`, () => {
fc.constant(new OddCommand()),
fc.nat().map(n => new CheckLessThanCommand(n + 1))
],
1000
{ disableReplayLog: true, maxCommands: 1000 }
),
cmds => {
const setup = () => ({
Expand Down Expand Up @@ -122,7 +122,9 @@ describe(`CommandsArbitrary (seed: ${seed})`, () => {
const out = fc.check(
fc.property(
fc.array(fc.nat(9), 0, 3),
fc.commands([fc.constant(new FailureCommand()), fc.constant(new SuccessCommand())]),
fc.commands([fc.constant(new FailureCommand()), fc.constant(new SuccessCommand())], {
disableReplayLog: true
}),
fc.array(fc.nat(9), 0, 3),
(validSteps1, cmds, validSteps2) => {
const setup = () => ({
Expand All @@ -145,7 +147,9 @@ describe(`CommandsArbitrary (seed: ${seed})`, () => {
const out = fc.check(
fc.property(
fc.array(fc.nat(9), 0, 3),
fc.commands([fc.constant(new FailureCommand()), fc.constant(new SuccessCommand())]),
fc.commands([fc.constant(new FailureCommand()), fc.constant(new SuccessCommand())], {
disableReplayLog: true
}),
fc.array(fc.nat(9), 0, 3),
(validSteps1, cmds, validSteps2) => {
if (String(cmds) !== '') {
Expand Down
23 changes: 23 additions & 0 deletions test/unit/check/model/ReplayPath.spec.ts
@@ -0,0 +1,23 @@
import * as fc from '../../../../lib/fast-check';

import { ReplayPath } from '../../../../src/check/model/ReplayPath';

const biasedBoolean = fc.frequency(
{ weight: 1000, arbitrary: fc.constant(true) },
{ weight: 1, arbitrary: fc.constant(false) }
);

describe('ReplayPath', () => {
it('Should be able to read back itself', () =>
fc.assert(
fc.property(fc.array(fc.boolean(), 0, 1000), (replayPath: boolean[]) => {
expect(ReplayPath.parse(ReplayPath.stringify(replayPath))).toEqual(replayPath);
})
));
it('Should be able to read back itself (biased boolean)', () =>
fc.assert(
fc.property(fc.array(biasedBoolean, 0, 1000), (replayPath: boolean[]) => {
expect(ReplayPath.parse(ReplayPath.stringify(replayPath))).toEqual(replayPath);
})
));
});

0 comments on commit 827f18e

Please sign in to comment.