diff --git a/engine/src/main/java/net/jqwik/engine/properties/state/ShrinkableChainIteration.java b/engine/src/main/java/net/jqwik/engine/properties/state/ShrinkableChainIteration.java index 4567d964d..48dc5b659 100644 --- a/engine/src/main/java/net/jqwik/engine/properties/state/ShrinkableChainIteration.java +++ b/engine/src/main/java/net/jqwik/engine/properties/state/ShrinkableChainIteration.java @@ -6,9 +6,12 @@ import net.jqwik.api.*; import net.jqwik.api.state.*; +import org.jetbrains.annotations.*; + class ShrinkableChainIteration { final Shrinkable> shrinkable; private final Predicate precondition; + private final @Nullable Transformer transformer; final boolean accessState; final boolean changeState; @@ -31,18 +34,21 @@ private ShrinkableChainIteration( this.accessState = accessState; this.changeState = changeState; this.shrinkable = shrinkable; + // Transformer method might access state, so we need to cache the value here + // otherwise it might be evaluated with wrong state (e.g. after chain executes) + this.transformer = accessState ? shrinkable.value() : null; } @Override public String toString() { return String.format( "Iteration[accessState=%s, changeState=%s, transformation=%s]", - accessState, changeState, shrinkable.value().transformation() + accessState, changeState, transformer().transformation() ); } boolean isEndOfChain() { - return shrinkable.value().equals(Transformer.END_OF_CHAIN); + return transformer().equals(Transformer.END_OF_CHAIN); } Optional> precondition() { @@ -65,6 +71,6 @@ String transformation() { } Transformer transformer() { - return shrinkable.value(); + return transformer == null ? shrinkable.value() : transformer; } } diff --git a/engine/src/test/java/net/jqwik/engine/properties/state/ActionChainArbitraryTests.java b/engine/src/test/java/net/jqwik/engine/properties/state/ActionChainArbitraryTests.java index bda69b935..56026a411 100644 --- a/engine/src/test/java/net/jqwik/engine/properties/state/ActionChainArbitraryTests.java +++ b/engine/src/test/java/net/jqwik/engine/properties/state/ActionChainArbitraryTests.java @@ -119,6 +119,76 @@ ActionChainArbitrary xOrFailing() { .withMaxTransformations(30); } + static class SetMutatingChainState { + final List actualOps = new ArrayList<>(); + final Set set = new HashSet<>(); + + @Override + public String toString() { + return "set=" + set + ", actualOps=" + actualOps; + } + } + + @Property + void chainActionsAreProperlyDescribedEvenAfterChainExecution(@ForAll("setMutatingChain") ActionChain chain) { + SetMutatingChainState finalState = chain.run(); + + assertThat(chain.transformations()) + .describedAs("chain.transformations() should be the same as the list of operations in finalState.actualOps, final state is %s", finalState.set) + .isEqualTo(finalState.actualOps); + } + + @Provide + public ActionChainArbitrary setMutatingChain() { + return + ActionChain + .startWith(SetMutatingChainState::new) + // This is an action that does not depend on the state to produce the transformation + .addAction( + 1, + Action.just("clear anyway", state -> { + state.actualOps.add("clear anyway"); + state.set.clear(); + return state; + }) + ) + // Below actions depend on the state to derive the transformations + .addAction( + 1, + (Action.Dependent) + state -> + Arbitraries + .just( + state.set.isEmpty() + ? Transformer.noop() + : Transformer.mutate("clear " + state.set, set -> { + state.actualOps.add("clear " + set.set); + state.set.clear(); + }) + ) + ) + .addAction( + 2, + (Action.Dependent) + state -> + Arbitraries + .integers() + .between(1, 10) + .map(i -> { + if (state.set.contains(i)) { + return Transformer.noop(); + } else { + return Transformer.mutate("add " + i + " to " + state.set, newState -> { + newState.actualOps.add("add " + i + " to " + newState.set); + newState.set.add(i); + }); + } + } + ) + ) + .withMaxTransformations(5); + } + @Property void chainChoosesBetweenTwoActions(@ForAll("xOrY") ActionChain chain) { String result = chain.run();