From ec78c8026b3a40188e280fdeb82dcaaeccca4194 Mon Sep 17 00:00:00 2001 From: Nicolas DUBIEN Date: Sun, 27 Jan 2019 21:06:23 +0100 Subject: [PATCH 1/8] Add replay ability for commands Replaying previously executed runs is a must have feature for property based testing frameworks. Because of the specificities of commands, commands were not eligible to replay. This commit adds the replay capabilities to commands by specifying an extra parameter when defining them (replayPath). Please note that commands arbitraries should not be shared accross multiple runs. Related to #251 --- src/check/model/ReplayPath.ts | 9 +++ src/check/model/commands/CommandsArbitrary.ts | 79 +++++++++++++++++-- src/check/model/commands/CommandsIterable.ts | 11 ++- src/check/model/commands/CommandsSettings.ts | 5 ++ test/e2e/ReplayCommands.spec.ts | 50 ++++++++++++ test/e2e/model/CommandsArbitrary.spec.ts | 10 ++- test/unit/check/model/ReplayPath.spec.ts | 12 +++ .../model/commands/CommandsArbitrary.spec.ts | 53 ++++++++++++- .../model/commands/CommandsIterable.spec.ts | 15 ++-- 9 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 src/check/model/ReplayPath.ts create mode 100644 src/check/model/commands/CommandsSettings.ts create mode 100644 test/e2e/ReplayCommands.spec.ts create mode 100644 test/unit/check/model/ReplayPath.spec.ts diff --git a/src/check/model/ReplayPath.ts b/src/check/model/ReplayPath.ts new file mode 100644 index 00000000000..297925070ce --- /dev/null +++ b/src/check/model/ReplayPath.ts @@ -0,0 +1,9 @@ +/** @hidden */ +export class ReplayPath { + static parse(replayPathStr: string): boolean[] { + return [...replayPathStr].map(v => v === '1'); + } + static stringify(replayPath: boolean[]): string { + return replayPath.map(s => (s ? '1' : '0')).join(''); + } +} diff --git a/src/check/model/commands/CommandsArbitrary.ts b/src/check/model/commands/CommandsArbitrary.ts index 51e9279cce3..b83a6d138c9 100644 --- a/src/check/model/commands/CommandsArbitrary.ts +++ b/src/check/model/commands/CommandsArbitrary.ts @@ -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 */ @@ -17,16 +19,28 @@ class CommandsArbitrary { readonly oneCommandArb: Arbitrary>; readonly lengthArb: ArbitraryWithShrink; - constructor(commandArbs: Arbitrary>[], maxCommands: number) { + private replayPath: boolean[]; + private replayPathPosition: number; + constructor( + commandArbs: Arbitrary>[], + maxCommands: number, + replayPathStr: string | null, + readonly disableReplayLog: boolean + ) { super(); this.oneCommandArb = oneof(...commandArbs).map(c => new CommandWrapper(c)); this.lengthArb = nat(maxCommands); + this.replayPath = replayPathStr !== null ? ReplayPath.parse(replayPathStr) : []; + this.replayPathPosition = 0; + } + private metadataForReplay() { + return this.disableReplayLog ? '' : `replayPath=${JSON.stringify(ReplayPath.stringify(this.replayPath))}`; } private wrapper( items: Shrinkable>[], shrunkOnce: boolean ): Shrinkable> { - 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)) ); } @@ -39,11 +53,31 @@ class CommandsArbitrary>[]) { + const items: typeof itemsRaw = []; + for (let idx = 0; idx !== itemsRaw.length; ++idx) { + const c = itemsRaw[idx]; + if (this.replayPathPosition < this.replayPath.length) { + // we still have replay data for this execution, we apply it + if (this.replayPath[this.replayPathPosition]) items.push(c); + // checking for mismatches to stop the run in case the replay data is wrong + else if (c.value_.hasRan) throw new Error(`Mismatch between replayPath and real execution`); + } else { + // we do not any replay data, we check the real status + if (c.value_.hasRan) { + this.replayPath.push(true); + items.push(c); + } else this.replayPath.push(false); + } + ++this.replayPathPosition; + } + return items; + } private shrinkImpl( itemsRaw: Shrinkable>[], shrunkOnce: boolean ): Stream>[]> { - const items = itemsRaw.filter(c => c.value_.hasRan); // filter out commands that have not been executed + const items = this.filterNonExecuted(itemsRaw); // filter out commands that have not been executed if (items.length === 0) { return Stream.nil>[]>(); } @@ -105,11 +139,46 @@ function commands( commandArbs: Arbitrary>[], maxCommands?: number ): Arbitrary>>; +/** + * 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( + commandArbs: Arbitrary>[], + settings?: CommandsSettings +): Arbitrary>>; +/** + * 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( + commandArbs: Arbitrary>[], + settings?: CommandsSettings +): Arbitrary>>; function commands( commandArbs: Arbitrary>[], - maxCommands?: number + settings?: number | CommandsSettings ): Arbitrary>> { - return new CommandsArbitrary(commandArbs, maxCommands != null ? maxCommands : 10); + const maxCommands: number = + settings != null && typeof settings === 'number' + ? settings + : settings != null && settings.maxCommands != null + ? settings.maxCommands + : 10; + const replayPath: string | null = + settings != null && typeof settings !== 'number' ? settings.replayPath || null : null; + const disableReplayLog = settings != null && typeof settings !== 'number' && !!settings.disableReplayLog; + return new CommandsArbitrary(commandArbs, maxCommands != null ? maxCommands : 10, replayPath, disableReplayLog); } export { commands }; diff --git a/src/check/model/commands/CommandsIterable.ts b/src/check/model/commands/CommandsIterable.ts index 8aa6a6d28d1..b6c4b8abea4 100644 --- a/src/check/model/commands/CommandsIterable.ts +++ b/src/check/model/commands/CommandsIterable.ts @@ -4,17 +4,22 @@ import { CommandWrapper } from './CommandWrapper'; /** @hidden */ export class CommandsIterable implements Iterable> { - constructor(readonly commands: CommandWrapper[]) {} + constructor( + readonly commands: CommandWrapper[], + readonly metadataForReplay: () => string + ) {} [Symbol.iterator](): Iterator> { 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; } } diff --git a/src/check/model/commands/CommandsSettings.ts b/src/check/model/commands/CommandsSettings.ts new file mode 100644 index 00000000000..4e8f73cf0d0 --- /dev/null +++ b/src/check/model/commands/CommandsSettings.ts @@ -0,0 +1,5 @@ +export interface CommandsSettings { + maxCommands?: number; + disableReplayLog?: boolean; + replayPath?: string; +} diff --git a/test/e2e/ReplayCommands.spec.ts b/test/e2e/ReplayCommands.spec.ts new file mode 100644 index 00000000000..eef9a05c4e2 --- /dev/null +++ b/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 { + constructor(readonly v: number) {} + check = (m: Readonly) => true; + run = (m: Model, r: Real) => (m.counter += this.v); + toString = () => `IncBy(${this.v})`; +} +class DecPosBy implements fc.Command { + constructor(readonly v: number) {} + check = (m: Readonly) => m.counter > 0; + run = (m: Model, r: Real) => (m.counter -= this.v); + toString = () => `DecPosBy(${this.v})`; +} +class AlwaysPos implements fc.Command { + check = (m: Readonly) => 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); + }); +}); diff --git a/test/e2e/model/CommandsArbitrary.spec.ts b/test/e2e/model/CommandsArbitrary.spec.ts index 07c2d4e46b7..88f7cecfa53 100644 --- a/test/e2e/model/CommandsArbitrary.spec.ts +++ b/test/e2e/model/CommandsArbitrary.spec.ts @@ -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 = () => ({ @@ -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 = () => ({ @@ -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) !== '') { diff --git a/test/unit/check/model/ReplayPath.spec.ts b/test/unit/check/model/ReplayPath.spec.ts new file mode 100644 index 00000000000..e43fb319f07 --- /dev/null +++ b/test/unit/check/model/ReplayPath.spec.ts @@ -0,0 +1,12 @@ +import * as fc from '../../../../lib/fast-check'; + +import { ReplayPath } from '../../../../src/check/model/ReplayPath'; + +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); + }) + )); +}); diff --git a/test/unit/check/model/commands/CommandsArbitrary.spec.ts b/test/unit/check/model/commands/CommandsArbitrary.spec.ts index 1bab1ea5267..55fe387c189 100644 --- a/test/unit/check/model/commands/CommandsArbitrary.spec.ts +++ b/test/unit/check/model/commands/CommandsArbitrary.spec.ts @@ -149,7 +149,7 @@ describe('CommandWrapper', () => { }) )); it('Should provide commands which have not run yet', () => { - const commandsArb = commands([constant(new SuccessCommand({ data: [] }))]); + const commandsArb = commands([constant(new SuccessCommand({ data: [] }))], { disableReplayLog: true }); const arbs = genericTuple([nat(16), commandsArb, nat(16)] as Arbitrary[]); const assertCommandsNotStarted = (shrinkable: Shrinkable<[number, Iterable, number]>) => { expect(String(shrinkable.value_[1])).toEqual(''); @@ -216,5 +216,56 @@ describe('CommandWrapper', () => { }) ); }); + it.only('Should shrink the same way when based on replay data', () => { + fc.assert( + fc.property(fc.integer().noShrink(), fc.nat(100), (seed, numValues) => { + // create unused logOnCheck + const logOnCheck: { data: string[] } = { data: [] }; + + // generate scenario and simulate execution + const rng = prand.xorshift128plus(seed); + const refArbitrary = commands([ + constant(new SuccessCommand(logOnCheck)), + constant(new SkippedCommand(logOnCheck)), + constant(new FailureCommand(logOnCheck)), + nat().map(v => new SuccessIdCommand(v)) + ]); + const refShrinkable: Shrinkable> = refArbitrary.generate(new Random(rng)); + simulateCommands(refShrinkable.value_); + + // trigger computation of replayPath + // and extract shrinks for ref + const refShrinks = [ + ...refShrinkable + .shrink() + .take(numValues) + .map(s => [...s.value_].map(c => c.toString())) + ]; + + // extract replayPath + const replayPath = /\/\*replayPath=['"](.*)['"]\*\//.exec(refShrinkable.value_.toString())![1]; + + // generate scenario but do not simulate execution + const noExecShrinkable: Shrinkable> = commands( + [ + constant(new SuccessCommand(logOnCheck)), + constant(new SkippedCommand(logOnCheck)), + constant(new FailureCommand(logOnCheck)), + nat().map(v => new SuccessIdCommand(v)) + ], + { replayPath } + ).generate(new Random(rng)); + + // check shrink values are identical + const noExecShrinks = [ + ...noExecShrinkable + .shrink() + .take(numValues) + .map(s => [...s.value_].map(c => c.toString())) + ]; + expect(noExecShrinks).toEqual(refShrinks); + }) + ); + }); }); }); diff --git a/test/unit/check/model/commands/CommandsIterable.spec.ts b/test/unit/check/model/commands/CommandsIterable.spec.ts index 8b527b12d41..9c947871546 100644 --- a/test/unit/check/model/commands/CommandsIterable.spec.ts +++ b/test/unit/check/model/commands/CommandsIterable.spec.ts @@ -27,7 +27,7 @@ describe('CommandsIterable', () => { it('Should not reset hasRun flag on iteration', () => fc.assert( fc.property(fc.array(fc.boolean()), runFlags => { - const commands = [...new CommandsIterable(buildAlreadyRanCommands(runFlags))]; + const commands = [...new CommandsIterable(buildAlreadyRanCommands(runFlags), () => '')]; for (let idx = 0; idx !== runFlags.length; ++idx) { expect(commands[idx].hasRan).toEqual(runFlags[idx]); } @@ -36,7 +36,7 @@ describe('CommandsIterable', () => { it('Should not reset hasRun flag on the original iterable on clone', () => fc.assert( fc.property(fc.array(fc.boolean()), runFlags => { - const originalIterable = new CommandsIterable(buildAlreadyRanCommands(runFlags)); + const originalIterable = new CommandsIterable(buildAlreadyRanCommands(runFlags), () => ''); originalIterable[cloneMethod](); const commands = [...originalIterable]; for (let idx = 0; idx !== runFlags.length; ++idx) { @@ -47,20 +47,21 @@ describe('CommandsIterable', () => { it('Should reset hasRun flag for the clone on clone', () => fc.assert( fc.property(fc.array(fc.boolean()), runFlags => { - const commands = [...new CommandsIterable(buildAlreadyRanCommands(runFlags))[cloneMethod]()]; + const commands = [...new CommandsIterable(buildAlreadyRanCommands(runFlags), () => '')[cloneMethod]()]; for (let idx = 0; idx !== runFlags.length; ++idx) { expect(commands[idx].hasRan).toBe(false); } }) )); - it('Should only print ran commands', () => + it('Should only print ran commands and metadata if any', () => fc.assert( - fc.property(fc.array(fc.boolean()), runFlags => { - const commandsIterable = new CommandsIterable(buildAlreadyRanCommands(runFlags)); - const expectedToString = runFlags + fc.property(fc.array(fc.boolean()), fc.fullUnicodeString(), (runFlags, metadata) => { + const commandsIterable = new CommandsIterable(buildAlreadyRanCommands(runFlags), () => metadata); + const expectedCommands = runFlags .map((hasRan, idx) => (hasRan ? String(idx) : '')) .filter(s => s !== '') .join(','); + const expectedToString = metadata.length !== 0 ? `${expectedCommands} /*${metadata}*/` : expectedCommands; expect(commandsIterable.toString()).toEqual(expectedToString); }) )); From 0cf848ef107bb7f60863a982a61f537382cc55dd Mon Sep 17 00:00:00 2001 From: Nicolas DUBIEN Date: Sun, 27 Jan 2019 21:15:52 +0100 Subject: [PATCH 2/8] Delay init of replay so that arbitrary can be re-used --- src/check/model/commands/CommandsArbitrary.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/check/model/commands/CommandsArbitrary.ts b/src/check/model/commands/CommandsArbitrary.ts index b83a6d138c9..57465fa87f2 100644 --- a/src/check/model/commands/CommandsArbitrary.ts +++ b/src/check/model/commands/CommandsArbitrary.ts @@ -24,13 +24,13 @@ class CommandsArbitrary>[], maxCommands: number, - replayPathStr: string | null, + readonly sourceReplayPath: string | null, readonly disableReplayLog: boolean ) { super(); this.oneCommandArb = oneof(...commandArbs).map(c => new CommandWrapper(c)); this.lengthArb = nat(maxCommands); - this.replayPath = replayPathStr !== null ? ReplayPath.parse(replayPathStr) : []; + this.replayPath = []; // updated at first shrink this.replayPathPosition = 0; } private metadataForReplay() { @@ -51,9 +51,13 @@ class CommandsArbitrary>[]) { + if (this.replayPathPosition === 0) { + this.replayPath = this.sourceReplayPath !== null ? ReplayPath.parse(this.sourceReplayPath) : []; + } const items: typeof itemsRaw = []; for (let idx = 0; idx !== itemsRaw.length; ++idx) { const c = itemsRaw[idx]; From 9650dabcd2431e26ce9852ef43c27e8a5207302c Mon Sep 17 00:00:00 2001 From: Nicolas DUBIEN Date: Wed, 30 Jan 2019 00:23:58 +0100 Subject: [PATCH 3/8] Document CommandsSettings and replay of commands --- documentation/Arbitraries.md | 12 ++++++++++++ documentation/Tips.md | 4 ++++ src/check/model/commands/CommandsSettings.ts | 14 ++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/documentation/Arbitraries.md b/documentation/Arbitraries.md index c7c4882fe9a..fc0ca729399 100644 --- a/documentation/Arbitraries.md +++ b/documentation/Arbitraries.md @@ -169,7 +169,19 @@ While `fc.array` or any other array arbitrary could be used to generate such dat Possible signatures: - `fc.commands(commandArbs: Arbitrary>[], maxCommands?: number)` arrays of `Command` that can be ingested by `fc.modelRun` +- `fc.commands(commandArbs: Arbitrary>[], settings: CommandsSettings)` arrays of `Command` that can be ingested by `fc.modelRun` - `fc.commands(commandArbs: Arbitrary>[], maxCommands?: number)` arrays of `AsyncCommand` that can be ingested by `fc.asyncModelRun` +- `fc.commands(commandArbs: Arbitrary>[], 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 diff --git a/documentation/Tips.md b/documentation/Tips.md index 1be0afd8999..6d618148a5e 100644 --- a/documentation/Tips.md +++ b/documentation/Tips.md @@ -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: @@ -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. diff --git a/src/check/model/commands/CommandsSettings.ts b/src/check/model/commands/CommandsSettings.ts index 4e8f73cf0d0..40be878c5ac 100644 --- a/src/check/model/commands/CommandsSettings.ts +++ b/src/check/model/commands/CommandsSettings.ts @@ -1,5 +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; } From c4ad387ce515756e7bd42acfc8a55b1daf48d85a Mon Sep 17 00:00:00 2001 From: Nicolas DUBIEN Date: Wed, 30 Jan 2019 00:53:50 +0100 Subject: [PATCH 4/8] Optimize replayPath size --- src/check/model/ReplayPath.ts | 41 ++++++++++++++++++++++-- test/unit/check/model/ReplayPath.spec.ts | 11 +++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/check/model/ReplayPath.ts b/src/check/model/ReplayPath.ts index 297925070ce..4c7c7fd014d 100644 --- a/src/check/model/ReplayPath.ts +++ b/src/check/model/ReplayPath.ts @@ -1,9 +1,46 @@ +/** @hidden */ +interface Count { + value: boolean; + count: number; +} + /** @hidden */ export class ReplayPath { static parse(replayPathStr: string): boolean[] { - return [...replayPathStr].map(v => v === '1'); + if (replayPathStr.length % 2 !== 0) throw new Error(`Invalid replayPath ${JSON.stringify(replayPathStr)}`); + + const replayPath: boolean[] = []; + for (let idx = 0; idx !== replayPathStr.length; idx += 2) { + const count = this.b64ToInt(replayPathStr.charAt(idx)) + 1; + const value = replayPathStr.charAt(idx + 1) === '1'; + for (let num = 0; num !== count; ++num) replayPath.push(value); + } + return replayPath; } static stringify(replayPath: boolean[]): string { - return replayPath.map(s => (s ? '1' : '0')).join(''); + 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; + }, []); + return aggregatedPath + .map(({ value, count }) => { + const b64 = this.intToB64(count - 1); + return value ? `${b64}1` : `${b64}0`; + }) + .join(''); + } + 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; } } diff --git a/test/unit/check/model/ReplayPath.spec.ts b/test/unit/check/model/ReplayPath.spec.ts index e43fb319f07..d6595acb0bc 100644 --- a/test/unit/check/model/ReplayPath.spec.ts +++ b/test/unit/check/model/ReplayPath.spec.ts @@ -2,6 +2,11 @@ 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( @@ -9,4 +14,10 @@ describe('ReplayPath', () => { 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); + }) + )); }); From 1afec16ebeda01b6922556982745b2cca5816511 Mon Sep 17 00:00:00 2001 From: Nicolas DUBIEN Date: Wed, 30 Jan 2019 01:29:46 +0100 Subject: [PATCH 5/8] Even shorter ReplayPath --- src/check/model/ReplayPath.ts | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/check/model/ReplayPath.ts b/src/check/model/ReplayPath.ts index 4c7c7fd014d..04ba7318e6b 100644 --- a/src/check/model/ReplayPath.ts +++ b/src/check/model/ReplayPath.ts @@ -7,12 +7,21 @@ interface Count { /** @hidden */ export class ReplayPath { static parse(replayPathStr: string): boolean[] { - if (replayPathStr.length % 2 !== 0) throw new Error(`Invalid replayPath ${JSON.stringify(replayPathStr)}`); + 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 !== replayPathStr.length; idx += 2) { - const count = this.b64ToInt(replayPathStr.charAt(idx)) + 1; - const value = replayPathStr.charAt(idx + 1) === '1'; + 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; @@ -24,12 +33,15 @@ export class ReplayPath { else counts[counts.length - 1].count += 1; return counts; }, []); - return aggregatedPath - .map(({ value, count }) => { - const b64 = this.intToB64(count - 1); - return value ? `${b64}1` : `${b64}0`; - }) - .join(''); + 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 From 41af5ceb21dbcd2c2a152db8694908d97992abf8 Mon Sep 17 00:00:00 2001 From: Nicolas DUBIEN Date: Sun, 3 Feb 2019 22:54:14 +0100 Subject: [PATCH 6/8] Simplify commands implementation --- src/check/model/commands/CommandsArbitrary.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/check/model/commands/CommandsArbitrary.ts b/src/check/model/commands/CommandsArbitrary.ts index 57465fa87f2..b416e2085ec 100644 --- a/src/check/model/commands/CommandsArbitrary.ts +++ b/src/check/model/commands/CommandsArbitrary.ts @@ -173,16 +173,13 @@ function commands>[], settings?: number | CommandsSettings ): Arbitrary>> { - const maxCommands: number = - settings != null && typeof settings === 'number' - ? settings - : settings != null && settings.maxCommands != null - ? settings.maxCommands - : 10; - const replayPath: string | null = - settings != null && typeof settings !== 'number' ? settings.replayPath || null : null; - const disableReplayLog = settings != null && typeof settings !== 'number' && !!settings.disableReplayLog; - return new CommandsArbitrary(commandArbs, maxCommands != null ? maxCommands : 10, replayPath, disableReplayLog); + 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 }; From bc81f6314ca301b4cc3e63e93c5c19ec5b1a14a9 Mon Sep 17 00:00:00 2001 From: Nicolas DUBIEN Date: Sun, 3 Feb 2019 23:28:23 +0100 Subject: [PATCH 7/8] Simplify filtering of commands with or without replay data --- src/check/model/commands/CommandsArbitrary.ts | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/src/check/model/commands/CommandsArbitrary.ts b/src/check/model/commands/CommandsArbitrary.ts index b416e2085ec..c7a7836a5d4 100644 --- a/src/check/model/commands/CommandsArbitrary.ts +++ b/src/check/model/commands/CommandsArbitrary.ts @@ -54,34 +54,43 @@ class CommandsArbitrary>[]) { + /** Filter commands based on the real status of the execution */ + private filterOnExecution(itemsRaw: Shrinkable>[]) { + 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>[]) { + 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>[]) { if (this.replayPathPosition === 0) { this.replayPath = this.sourceReplayPath !== null ? ReplayPath.parse(this.sourceReplayPath) : []; } - const items: typeof itemsRaw = []; - for (let idx = 0; idx !== itemsRaw.length; ++idx) { - const c = itemsRaw[idx]; - if (this.replayPathPosition < this.replayPath.length) { - // we still have replay data for this execution, we apply it - if (this.replayPath[this.replayPathPosition]) items.push(c); - // checking for mismatches to stop the run in case the replay data is wrong - else if (c.value_.hasRan) throw new Error(`Mismatch between replayPath and real execution`); - } else { - // we do not any replay data, we check the real status - if (c.value_.hasRan) { - this.replayPath.push(true); - items.push(c); - } else this.replayPath.push(false); - } - ++this.replayPathPosition; - } + const items = + this.replayPathPosition < this.replayPath.length + ? this.filterOnReplay(itemsRaw) + : this.filterOnExecution(itemsRaw); + this.replayPathPosition += itemsRaw.length; return items; } private shrinkImpl( itemsRaw: Shrinkable>[], shrunkOnce: boolean ): Stream>[]> { - const items = this.filterNonExecuted(itemsRaw); // 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>[]>(); } From 835b8d122f2d1ca38cffa27ff457ea2c6b27a308 Mon Sep 17 00:00:00 2001 From: Nicolas DUBIEN Date: Sun, 3 Feb 2019 23:36:25 +0100 Subject: [PATCH 8/8] Unskip skipped tests in commands --- test/unit/check/model/commands/CommandsArbitrary.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/check/model/commands/CommandsArbitrary.spec.ts b/test/unit/check/model/commands/CommandsArbitrary.spec.ts index 55fe387c189..6cced013271 100644 --- a/test/unit/check/model/commands/CommandsArbitrary.spec.ts +++ b/test/unit/check/model/commands/CommandsArbitrary.spec.ts @@ -216,7 +216,7 @@ describe('CommandWrapper', () => { }) ); }); - it.only('Should shrink the same way when based on replay data', () => { + it('Should shrink the same way when based on replay data', () => { fc.assert( fc.property(fc.integer().noShrink(), fc.nat(100), (seed, numValues) => { // create unused logOnCheck