quickcheck-dynamic 3.3.1 → 3.4.0
raw patch · 10 files changed
+218/−61 lines, 10 files
Files
- CHANGELOG.md +5/−1
- quickcheck-dynamic.cabal +4/−1
- src/Test/QuickCheck/DynamicLogic/Internal.hs +1/−1
- src/Test/QuickCheck/DynamicLogic/Quantify.hs +23/−8
- src/Test/QuickCheck/StateModel.hs +89/−40
- src/Test/QuickCheck/StateModel/Variables.hs +9/−1
- test/Spec.hs +4/−0
- test/Spec/DynamicLogic/CounterModel.hs +57/−0
- test/Spec/DynamicLogic/RegistryModel.hs +10/−9
- test/Test/QuickCheck/DynamicLogic/QuantifySpec.hs +16/−0
CHANGELOG.md view
@@ -7,9 +7,13 @@ As a minor extension, we also keep a semantic version for the `UNRELEASED` changes. -## UNRELEASED+## 3.4.0 - 2024-03-01 * Added some lightweight negative-shrinking based on a simple dependency analysis.+* Added the option to return errors from actions by defining `type Error state`.+ When this is defined `perform` has return type `m (Either (Error state) (Realized m a))`,+ when it is left as the default the type remains `m (Realized m a)`.+* Changed `withGenQ` to _require_ a predicate when defining a `Quantification`. **Note**: This is technically a breaking change as the interface changed ## 3.3.1
quickcheck-dynamic.cabal view
@@ -1,6 +1,6 @@ cabal-version: 2.2 name: quickcheck-dynamic-version: 3.3.1+version: 3.4.0 license: Apache-2.0 license-files: LICENSE@@ -46,6 +46,7 @@ ImportQualifiedPost LambdaCase MultiParamTypeClasses+ MultiWayIf PatternSynonyms QuantifiedConstraints RankNTypes@@ -89,8 +90,10 @@ main-is: Spec.hs hs-source-dirs: test other-modules:+ Spec.DynamicLogic.CounterModel Spec.DynamicLogic.Registry Spec.DynamicLogic.RegistryModel+ Test.QuickCheck.DynamicLogic.QuantifySpec ghc-options: -rtsopts build-depends:
src/Test/QuickCheck/DynamicLogic/Internal.hs view
@@ -162,7 +162,7 @@ instance StateModel s => Eq (FailingAction s) where ErrorFail s == ErrorFail s' = s == s'- ActionFail (a :: ActionWithPolarity s a) == ActionFail (a' :: ActionWithPolarity s a')+ ActionFail (a :: ActionWithPolarity s a) == ActionFail (a' :: ActionWithPolarity s' a') | Just Refl <- eqT @a @a' = a == a' _ == _ = False
src/Test/QuickCheck/DynamicLogic/Quantify.hs view
@@ -32,10 +32,22 @@ import Test.QuickCheck.DynamicLogic.CanGenerate import Test.QuickCheck.StateModel --- | A `Quantification` over a type @a@ is a generator that can be used with--- `Plutus.Contract.Test.ContractModel.forAllQ` to generate random values in--- DL scenarios. In addition to a QuickCheck generator a `Quantification` contains a shrinking--- strategy that ensures that shrunk values stay in the range of the generator.+-- | A `Quantification` over a type @a@ is a generator that can be used to generate random values in+-- DL scenarios.+--+-- A `Quantification` is similar to a `Test.QuickCheck.Arbitrary`, it groups together:+--+-- * A standard QuickCheck _generator_ in the `Gen` monad, which can be "empty",+-- * A _shrinking_ strategy for generated values in the case of a+-- failures ensuring they stay within the domain,+-- * A _predicate_ allowing finer grained control on generation+-- and shrinking process, e.g in the case the range of the generator+-- depends on trace context.+--+-- NOTE: Leaving the possibility of generating `Nothing` is useful to simplify the generation+-- process for `elements` or `frequency` which may normally crash when the list to select+-- elements from is empty. This makes writing `DL` formulas cleaner, removing the need to+-- handle non-existence cases explicitly. data Quantification a = Quantification { genQ :: Maybe (Gen a) , isaQ :: a -> Bool@@ -51,10 +63,11 @@ shrinkQ :: Quantification a -> a -> [a] shrinkQ q a = filter (isaQ q) (shrQ q a) --- | Wrap a `Gen a` generator in a `Quantification a`.--- Uses given shrinker.-withGenQ :: Gen a -> (a -> [a]) -> Quantification a-withGenQ gen = Quantification (Just gen) (const True)+-- | Construct a `Quantification a` from its constituents.+-- Note the predicate provided is used to restrict both the range of values+-- generated and the list of possible shrinked values.+withGenQ :: Gen a -> (a -> Bool) -> (a -> [a]) -> Quantification a+withGenQ gen isA = Quantification (Just $ gen `suchThat` isA) isA -- | Pack up an `Arbitrary` instance as a `Quantification`. Treats all values as being in range. arbitraryQ :: Arbitrary a => Quantification a@@ -228,6 +241,8 @@ from (x : xs) = (x, xs) from [] = error "quantify: impossible" +-- | Turns a `Quantification` into a `Property` to enable QuickChecking its+-- validity. validQuantification :: Show a => Quantification a -> Property validQuantification q = forAllShrink (fromJust $ genQ q) (shrinkQ q) $ isaQ q
src/Test/QuickCheck/StateModel.hs view
@@ -36,7 +36,6 @@ computePrecondition, computeArbitraryAction, computeShrinkAction,- failureResult, ) where import Control.Monad@@ -47,14 +46,14 @@ import Data.Data import Data.Kind import Data.List+import Data.Monoid (Endo (..)) import Data.Set qualified as Set+import Data.Void import GHC.Generics-import GHC.Stack import Test.QuickCheck as QC import Test.QuickCheck.DynamicLogic.SmartShrinking import Test.QuickCheck.Monadic import Test.QuickCheck.StateModel.Variables-import Data.Monoid (Endo (..)) -- | The typeclass users implement to define a model against which to validate some implementation. --@@ -97,7 +96,7 @@ -- @ -- data Action RegState a where -- Spawn :: Action RegState ThreadId- -- Register :: String -> Var ThreadId -> Action RegState (Either ErrorCall ())+ -- Register :: String -> Var ThreadId -> Action RegState () -- KillThread :: Var ThreadId -> Action RegState () -- @ --@@ -105,6 +104,13 @@ -- anything. data Action state a + -- | The type of errors that actions can throw. If this is defined as anything+ -- other than `Void` `perform` is required to return `Either (Error state) a`+ -- instead of `a`.+ type Error state++ type Error state = Void+ -- | Display name for `Action`. -- This is useful to provide sensible statistics about the distribution of `Action`s run -- when checking a property.@@ -154,6 +160,22 @@ deriving instance (forall a. Show (Action state a)) => Show (Any (Action state)) +-- | The result required of `perform` depending on the `Error` type+-- of a state model. If there are no errors, `Error state = Void`, and+-- so we don't need to specify if the action failed or not.+type family PerformResult e a where+ PerformResult Void a = a+ PerformResult e a = Either e a++class IsPerformResult e a where+ performResultToEither :: PerformResult e a -> Either e a++instance {-# OVERLAPPING #-} IsPerformResult Void a where+ performResultToEither = Right++instance {-# OVERLAPPABLE #-} (PerformResult e a ~ Either e a) => IsPerformResult e a where+ performResultToEither = id+ -- TODO: maybe it makes sense to write -- out a long list of these instances type family Realized (m :: Type -> Type) a :: Type@@ -166,6 +188,9 @@ newtype PostconditionM m a = PostconditionM {runPost :: WriterT (Endo Property, Endo Property) m a} deriving (Functor, Applicative, Monad) +instance MonadTrans PostconditionM where+ lift = PostconditionM . lift+ -- | Apply the property transformation to the property after evaluating -- the postcondition. Useful for collecting statistics while avoiding -- duplication between `monitoring` and `postcondition`.@@ -176,7 +201,7 @@ counterexamplePost :: Monad m => String -> PostconditionM m () counterexamplePost c = PostconditionM $ tell (mempty, Endo $ counterexample c) -class Monad m => RunModel state m where+class (forall a. Show (Action state a), Monad m) => RunModel state m where -- | Perform an `Action` in some `state` in the `Monad` `m`. This -- is the function that's used to exercise the actual stateful -- implementation, usually through various side-effects as permitted@@ -187,40 +212,47 @@ -- -- The `Lookup` parameter provides an /environment/ to lookup `Var -- a` instances from previous steps.- perform :: forall a. Typeable a => state -> Action state a -> LookUp m -> m (Realized m a)+ perform :: Typeable a => state -> Action state a -> LookUp m -> m (PerformResult (Error state) (Realized m a)) -- | Postcondition on the `a` value produced at some step. -- The result is `assert`ed and will make the property fail should it be `False`. This is useful -- to check the implementation produces expected values.- postcondition :: forall a. (state, state) -> Action state a -> LookUp m -> Realized m a -> PostconditionM m Bool+ postcondition :: (state, state) -> Action state a -> LookUp m -> Realized m a -> PostconditionM m Bool postcondition _ _ _ _ = pure True -- | Postcondition on the result of running a _negative_ `Action`. -- The result is `assert`ed and will make the property fail should it be `False`. This is useful -- to check the implementation produces e.g. the expected errors or to check that the SUT hasn't -- been updated during the execution of the negative action.- postconditionOnFailure :: forall a. (state, state) -> Action state a -> LookUp m -> Realized m a -> PostconditionM m Bool+ postconditionOnFailure :: (state, state) -> Action state a -> LookUp m -> Either (Error state) (Realized m a) -> PostconditionM m Bool postconditionOnFailure _ _ _ _ = pure True -- | Allows the user to attach additional information to the `Property` at each step of the process. -- This function is given the full transition that's been executed, including the start and ending -- `state`, the `Action`, the current environment to `Lookup` and the value produced by `perform` -- while executing this step.- monitoring :: forall a. (state, state) -> Action state a -> LookUp m -> Realized m a -> Property -> Property+ monitoring :: (state, state) -> Action state a -> LookUp m -> Either (Error state) (Realized m a) -> Property -> Property monitoring _ _ _ _ prop = prop --- | Indicate that the result of an action (in `perform`)--- should not be inspected by the postcondition or appear--- in a positive test. Useful when we want to give a type--- for an `Action` like `SomeAct :: Action SomeState SomeType`--- instead of `SomeAct :: Action SomeState (Either SomeError SomeType)`--- but still need to return something in `perform` in the failure case.-failureResult :: HasCallStack => a-failureResult = error "A result of a failing action has been erronesouly inspected"+ -- | Allows the user to attach additional information to the `Property` if a positive action fails.+ monitoringFailure :: state -> Action state a -> LookUp m -> Error state -> Property -> Property+ monitoringFailure _ _ _ _ prop = prop -computePostcondition :: forall m state a. RunModel state m => (state, state) -> ActionWithPolarity state a -> LookUp m -> Realized m a -> PostconditionM m Bool+computePostcondition+ :: forall m state a+ . RunModel state m+ => (state, state)+ -> ActionWithPolarity state a+ -> LookUp m+ -> Either (Error state) (Realized m a)+ -> PostconditionM m Bool computePostcondition ss (ActionWithPolarity a p) l r- | p == PosPolarity = postcondition ss a l r+ | p == PosPolarity = case r of+ Right ra -> postcondition ss a l ra+ -- NOTE: this is actually redundant as this handled+ -- at the call site for this function, but this is+ -- good hygiene?+ Left _ -> pure False | otherwise = postconditionOnFailure ss a l r type LookUp m = forall a. Typeable a => Var a -> Realized m a@@ -249,7 +281,7 @@ lookUpVar :: Typeable a => Env m -> Var a -> Realized m a lookUpVar env v = case lookUpVarMaybe env v of- Nothing -> error $ "Variable " ++ show v ++ " is not bound!"+ Nothing -> error $ "Variable " ++ show v ++ " is not bound at type " ++ show (typeRep v) ++ "!" Just a -> a data WithUsedVars a = WithUsedVars VarContext a@@ -474,8 +506,12 @@ loop s ((var := act) : as) = loop (computeNextState @state s act var) as runActions- :: forall state m- . (StateModel state, RunModel state m)+ :: forall state m e+ . ( StateModel state+ , RunModel state m+ , e ~ Error state+ , forall a. IsPerformResult e a+ ) => Actions state -> PropertyM m (Annotated state, Env m) runActions (Actions_ rejected (Smart _ actions)) = loop initialAnnotatedState [] actions@@ -488,24 +524,37 @@ return (_s, reverse env) loop s env ((v := act) : as) = do pre $ computePrecondition s act- ret <- run $ perform (underlyingState s) (polarAction act) (lookUpVar env)+ ret <- run $ performResultToEither <$> perform (underlyingState s) (polarAction act) (lookUpVar env) let name = show (polarity act) ++ actionName (polarAction act) monitor $ tabulate "Actions" [name]- let var = unsafeCoerceVar v- s' = computeNextState s act var- env' = (var :== ret) : env monitor $ tabulate "Action polarity" [show $ polarity act]- monitor $ monitoring @state @m (underlyingState s, underlyingState s') (polarAction act) (lookUpVar env') ret- (b, (Endo mon, Endo onFail)) <-- run- . runWriterT- . runPost- $ computePostcondition @m- (underlyingState s, underlyingState s')- act- (lookUpVar env)- ret- monitor mon- unless b $ monitor onFail- assert b- loop s' env' as+ if+ | polarity act == PosPolarity+ , Left err <- ret -> do+ monitor $+ monitoringFailure @state @m+ (underlyingState s)+ (polarAction act)+ (lookUpVar env)+ err+ stop False+ | otherwise -> do+ let var = unsafeCoerceVar v+ s' = computeNextState s act var+ env'+ | Right val <- ret = (var :== val) : env+ | otherwise = env+ monitor $ monitoring @state @m (underlyingState s, underlyingState s') (polarAction act) (lookUpVar env') ret+ (b, (Endo mon, Endo onFail)) <-+ run+ . runWriterT+ . runPost+ $ computePostcondition @m+ (underlyingState s, underlyingState s')+ act+ (lookUpVar env)+ ret+ monitor mon+ unless b $ monitor onFail+ assert b+ loop s' env' as
src/Test/QuickCheck/StateModel/Variables.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE QuantifiedConstraints #-} {-# LANGUAGE UndecidableInstances #-} module Test.QuickCheck.StateModel.Variables (@@ -14,6 +15,7 @@ extendContext, isWellTyped, allVariables,+ isEmptyCtx, unsafeCoerceVar, unsafeNextVarIndex, ) where@@ -58,6 +60,9 @@ instance HasVariables a => HasVariables (Set a) where getAllVariables = getAllVariables . Set.toList +instance (forall a. HasVariables (f a)) => HasVariables (Any f) where+ getAllVariables (Some a) = getAllVariables a+ newtype HasNoVariables a = HasNoVariables a deriving via a instance Show a => Show (HasNoVariables a)@@ -101,8 +106,11 @@ -- The use of typeRep here is on purpose to avoid printing `Var` unnecessarily. showBinding (Some v) = show v ++ " :: " ++ show (typeRep v) +isEmptyCtx :: VarContext -> Bool+isEmptyCtx (VarCtx ctx) = null ctx+ isWellTyped :: Typeable a => Var a -> VarContext -> Bool-isWellTyped v (VarCtx ctx) = Some v `Set.member` ctx+isWellTyped v (VarCtx ctx) = not (null ctx) && Some v `Set.member` ctx -- TODO: check the invariant that no variable index is used -- twice at different types. This is generally not an issue
test/Spec.hs view
@@ -2,7 +2,9 @@ module Main (main) where +import Spec.DynamicLogic.CounterModel qualified import Spec.DynamicLogic.RegistryModel qualified+import Test.QuickCheck.DynamicLogic.QuantifySpec qualified import Test.Tasty main :: IO ()@@ -13,4 +15,6 @@ testGroup "dynamic logic" [ Spec.DynamicLogic.RegistryModel.tests+ , Spec.DynamicLogic.CounterModel.tests+ , Test.QuickCheck.DynamicLogic.QuantifySpec.tests ]
+ test/Spec/DynamicLogic/CounterModel.hs view
@@ -0,0 +1,57 @@+module Spec.DynamicLogic.CounterModel where++import Control.Monad.Reader+import Data.IORef+import Test.QuickCheck+import Test.QuickCheck.Extras+import Test.QuickCheck.Monadic+import Test.QuickCheck.StateModel+import Test.Tasty hiding (after)+import Test.Tasty.QuickCheck++data Counter = Counter Int+ deriving (Show, Generic)++deriving instance Show (Action Counter a)+deriving instance Eq (Action Counter a)+instance HasVariables (Action Counter a) where+ getAllVariables _ = mempty++instance StateModel Counter where+ data Action Counter a where+ Inc :: Action Counter ()+ Reset :: Action Counter Int++ initialState = Counter 0++ arbitraryAction _ _ = frequency [(5, pure $ Some Inc), (1, pure $ Some Reset)]++ nextState (Counter n) Inc _ = Counter (n + 1)+ nextState _ Reset _ = Counter 0++instance RunModel Counter (ReaderT (IORef Int) IO) where+ perform _ Inc _ = do+ ref <- ask+ lift $ modifyIORef ref succ+ perform _ Reset _ = do+ ref <- ask+ lift $ do+ n <- readIORef ref+ writeIORef ref 0+ pure n++ postcondition (Counter n, _) Reset _ res = pure $ n == res+ postcondition _ _ _ _ = pure True++prop_counter :: Actions Counter -> Property+prop_counter as = monadicIO $ do+ ref <- lift $ newIORef (0 :: Int)+ runPropertyReaderT (runActions as) ref+ assert True++tests :: TestTree+tests =+ testGroup+ "counter tests"+ [ testProperty "prop_conter" $ prop_counter+ ]
test/Spec/DynamicLogic/RegistryModel.hs view
@@ -40,10 +40,12 @@ data Action RegState a where Spawn :: Action RegState ThreadId WhereIs :: String -> Action RegState (Maybe ThreadId)- Register :: String -> Var ThreadId -> Action RegState (Either SomeException ())- Unregister :: String -> Action RegState (Either SomeException ())+ Register :: String -> Var ThreadId -> Action RegState ()+ Unregister :: String -> Action RegState () KillThread :: Var ThreadId -> Action RegState () + type Error RegState = SomeException+ precondition s (Register name tid) = name `Map.notMember` regs s && tid `notElem` Map.elems (regs s)@@ -109,7 +111,8 @@ instance RunModel RegState RegM where perform _ Spawn _ = do- lift $ forkIO (threadDelay 10000000)+ tid <- lift $ forkIO (threadDelay 10000000)+ pure $ Right tid perform _ (Register name tid) env = do reg <- ask lift $ try $ register reg name (env tid)@@ -118,24 +121,22 @@ lift $ try $ unregister reg name perform _ (WhereIs name) _ = do reg <- ask- lift $ whereis reg name+ res <- lift $ whereis reg name+ pure $ Right res perform _ (KillThread tid) env = do lift $ killThread (env tid) lift $ threadDelay 100+ pure $ Right () postcondition (s, _) (WhereIs name) env mtid = do pure $ (env <$> Map.lookup name (regs s)) == mtid- postcondition _ Register{} _ res = do- pure $ isRight res postcondition _ _ _ _ = pure True postconditionOnFailure (s, _) act@Register{} _ res = do monitorPost $ tabulate "Reason for -Register"- [ why s act- | Left{} <- [res]- ]+ [why s act] pure $ isLeft res postconditionOnFailure _s _ _ _ = pure True
+ test/Test/QuickCheck/DynamicLogic/QuantifySpec.hs view
@@ -0,0 +1,16 @@+module Test.QuickCheck.DynamicLogic.QuantifySpec where++import Test.QuickCheck (Arbitrary (..), Gen, Property)+import Test.QuickCheck.DynamicLogic.Quantify (validQuantification, withGenQ)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.QuickCheck (testProperty)++propWithGenQRestrictsValues :: Property+propWithGenQRestrictsValues =+ validQuantification $ withGenQ (arbitrary :: Gen Int) ((< 10) . abs) (shrink @Int)++tests :: TestTree+tests =+ testGroup+ "Quantification"+ [testProperty "withGenQ restricts possible generated values" propWithGenQRestrictsValues]