{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
module Test.MockFS (
tests
, propLockstep
-- * Unsafe: induce test failure
, setInduceFault
, setNoInduceFault
-- * Unsafe: set postcondition
, setPostconditionDefault
, setPostconditionNonVerbose
, setPostconditionVerbose
) where
import Prelude hiding (init)
import Control.Exception (catch, throwIO)
import Control.Monad (replicateM, (<=<))
import Control.Monad.Reader (ReaderT (..))
import Control.Monad.Trans (lift)
import Data.Bifunctor
import Data.Constraint
import Data.IORef
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Typeable
import qualified System.Directory as IO
import System.Directory (removeDirectoryRecursive)
import qualified System.IO as IO
import System.IO.Temp (createTempDirectory,
getCanonicalTemporaryDirectory)
import System.IO.Unsafe (unsafePerformIO)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit
import Test.Tasty.QuickCheck (testProperty)
import qualified Test.QuickCheck as QC
import Test.QuickCheck (Gen)
import Test.QuickCheck.StateModel hiding (vars)
import Test.QuickCheck.StateModel.Lockstep
import qualified Test.QuickCheck.StateModel.Lockstep.Defaults as Lockstep
import Test.QuickCheck.StateModel.Lockstep.Op.SumProd
import qualified Test.QuickCheck.StateModel.Lockstep.Run as Lockstep
import qualified Test.MockFS.Mock as Mock
import Test.MockFS.Mock (Dir (..), Err, File (..), Mock)
{-------------------------------------------------------------------------------
Model state
-------------------------------------------------------------------------------}
data FsState = FsState {
fsStateMock :: Mock
, fsStateStats :: Stats
}
deriving stock (Show)
initState :: FsState
initState = FsState {
fsStateMock = Mock.emptyMock
, fsStateStats = initStats
}
{-------------------------------------------------------------------------------
StateModel and 'RunModel' instances
-------------------------------------------------------------------------------}
type RealMonad = ReaderT FilePath IO
type FsVar a = ModelVar FsState a
type FsAct a = Action (Lockstep FsState) (Either Err a)
instance StateModel (Lockstep FsState) where
data Action (Lockstep FsState) a where
MkDir :: Dir -> FsAct ()
Open :: File -> FsAct (IO.Handle, File)
Write :: FsVar IO.Handle -> String -> FsAct ()
Close :: FsVar IO.Handle -> FsAct ()
Read :: Either (FsVar File) File -> FsAct String
initialState = Lockstep.initialState initState
nextState = Lockstep.nextState
precondition = Lockstep.precondition
arbitraryAction = Lockstep.arbitraryAction
shrinkAction = Lockstep.shrinkAction
instance RunModel (Lockstep FsState) RealMonad where
perform = \_st -> runIO
postcondition states action lookUp result = do
pc <- lift $ lift getPostcondition
runPostcondition pc states action lookUp result
monitoring = Lockstep.monitoring (Proxy @RealMonad)
deriving stock instance Show (Action (Lockstep FsState) a)
deriving stock instance Eq (Action (Lockstep FsState) a)
{-------------------------------------------------------------------------------
InLockstep instance
-------------------------------------------------------------------------------}
type FsVal a = ModelValue FsState a
type FsObs a = Observable FsState a
instance InLockstep FsState where
--
-- Model values
--
-- For model values, we must be sure that if we have a value of type
--
-- > FsVal IO.Handle
--
-- that it is a in fact mock handle. This means that here we cannot define
--
-- > ModelId :: a -> FsVal a
--
-- unlike in 'FsObs'.
data ModelValue FsState a where
MHandle :: Mock.MHandle -> FsVal IO.Handle
-- Rest is regular:
MErr :: Err -> FsVal Err
MFile :: File -> FsVal File
MString :: String -> FsVal String
MUnit :: () -> FsVal ()
MEither :: Either (FsVal a) (FsVal b) -> FsVal (Either a b)
MPair :: (FsVal a, FsVal b) -> FsVal (a, b)
--
-- Observable results
--
data Observable FsState a where
OHandle :: FsObs IO.Handle
OId :: (Show a, Typeable a, Eq a) => a -> FsObs a
OEither :: Either (FsObs a) (FsObs b) -> FsObs (Either a b)
OPair :: (FsObs a, FsObs b) -> FsObs (a, b)
observeModel :: FsVal a -> FsObs a
observeModel = \case
MEither x -> OEither $ bimap observeModel observeModel x
MPair x -> OPair $ bimap observeModel observeModel x
MErr x -> OId x
MString x -> OId x
MUnit x -> OId x
MFile x -> OId x
MHandle _ -> OHandle
--
-- Semantics
--
modelNextState :: forall a.
LockstepAction FsState a
-> ModelVarContext FsState
-> FsState -> (FsVal a, FsState)
modelNextState action ctx (FsState mock stats) =
auxStats $ runMock ctx action mock
where
auxStats :: (FsVal a, Mock) -> (FsVal a, FsState)
auxStats (result, state') =
(result, FsState state' $ updateStats action result stats)
--
-- Variables
--
type ModelOp FsState = Op
usedVars :: LockstepAction FsState a -> [AnyGVar (ModelOp FsState)]
usedVars = \case
MkDir{} -> []
Open{} -> []
Write h _ -> [SomeGVar h]
Close h -> [SomeGVar h]
Read (Left h) -> [SomeGVar h]
Read (Right _) -> []
--
-- Generation, shrinking and labelling
--
arbitraryWithVars env _mock = arbitraryFsAction env
shrinkWithVars env _mock = shrinkFsAction env
tagStep (_, FsState _ after) act = map show . tagFsAction after act
deriving stock instance Show (Observable FsState a)
deriving stock instance Eq (Observable FsState a)
deriving stock instance Show (FsVal a)
{-------------------------------------------------------------------------------
RunLockstep instance
-------------------------------------------------------------------------------}
instance RunLockstep FsState RealMonad where
observeReal ::
Proxy RealMonad
-> LockstepAction FsState a -> a -> FsObs a
observeReal _ = \case
MkDir{} -> OEither . bimap OId OId
Open{} -> OEither . bimap OId (OPair . bimap (const OHandle) OId)
Write{} -> OEither . bimap OId OId
Close{} -> OEither . bimap OId OId
Read{} -> OEither . bimap OId OId
showRealResponse _ = \case
MkDir{} -> Just Dict
Open{} -> Nothing
Write{} -> Just Dict
Close{} -> Just Dict
Read{} -> Just Dict
{-------------------------------------------------------------------------------
Interpreter against the model
-------------------------------------------------------------------------------}
runMock ::
ModelVarContext FsState
-> Action (Lockstep FsState) a
-> Mock -> (FsVal a, Mock)
runMock ctx = \case
MkDir d -> wrap MUnit . Mock.mMkDir d
Open f -> wrap (mOpen f) . Mock.mOpen f
Write h s -> wrap MUnit . Mock.mWrite (getHandle $ lookupVar ctx h) s
Close h -> wrap MUnit . Mock.mClose (getHandle $ lookupVar ctx h)
Read f -> wrap MString . Mock.mRead (either (getFile . lookupVar ctx) id f)
where
wrap :: (a -> FsVal b) -> (Either Err a, Mock) -> (FsVal (Either Err b), Mock)
wrap f = first (MEither . bimap MErr f)
mOpen :: File -> Mock.MHandle -> FsVal (IO.Handle, File)
mOpen f h = MPair (MHandle h, MFile f)
getHandle :: ModelValue FsState IO.Handle -> Mock.MHandle
getFile :: ModelValue FsState File -> File
getHandle (MHandle h) = h
getFile (MFile f) = f
{-------------------------------------------------------------------------------
Generator and shrinking
-------------------------------------------------------------------------------}
arbitraryFsAction ::
ModelVarContext FsState
-> Gen (Any (LockstepAction FsState))
arbitraryFsAction ctx = QC.oneof $ concat [
withoutVars
, case findVars ctx (Proxy @((Either Err (IO.Handle, File)))) of
[] -> []
vars -> withVars (QC.elements vars)
]
where
withoutVars :: [Gen (Any (LockstepAction FsState))]
withoutVars = [
fmap Some $ MkDir <$> genDir
, fmap Some $ Open <$> genFile
, fmap Some $ Read <$> (Right <$> genFile)
]
withVars ::
Gen (FsVar (Either Err (IO.Handle, File)))
-> [Gen (Any (LockstepAction FsState))]
withVars genVar = [
fmap Some $ Write <$> (handle <$> genVar) <*> genString
, fmap Some $ Close <$> (handle <$> genVar)
]
where
handle :: GVar Op (Either Err (IO.Handle, File)) -> GVar Op IO.Handle
handle = mapGVar (\op -> OpFst `OpComp` OpRight `OpComp` op)
genDir :: Gen Dir
genDir = do
n <- QC.choose (0, 3)
Dir <$> replicateM n (QC.elements ["x", "y", "z"])
genFile :: Gen File
genFile = File <$> genDir <*> QC.elements ["a", "b", "c"]
genString :: Gen String
genString = QC.sized $ \n -> replicateM n (QC.elements "ABC")
shrinkFsAction ::
ModelVarContext FsState
-> Action (Lockstep FsState) a -> [Any (LockstepAction FsState)]
shrinkFsAction ctx = \case
Open (File (Dir []) ('t' : n)) ->
[openTemp n' | n' <- QC.shrink (read n)]
Open _ ->
[openTemp 100]
Read (Right _) ->
[ Some $ Read (Left $ mapGVar (\op -> OpSnd `OpComp` OpRight `OpComp` op) v)
| v <- findVars ctx (Proxy @((Either Err (IO.Handle, File))))
]
_otherwise ->
[]
where
openTemp :: Int -> Any (LockstepAction FsState)
openTemp n = Some $ Open (File (Dir []) ('t' : show n))
{-------------------------------------------------------------------------------
Interpret 'Op' against 'ModelValue'
-------------------------------------------------------------------------------}
instance InterpretOp Op (ModelValue FsState) where
intOp OpId = Just
intOp OpFst = \case MPair x -> Just (fst x)
intOp OpSnd = \case MPair x -> Just (snd x)
intOp OpLeft = \case MEither x -> either Just (const Nothing) x
intOp OpRight = \case MEither x -> either (const Nothing) Just x
intOp (OpComp g f) = intOp g <=< intOp f
{-------------------------------------------------------------------------------
Interpreter for IO
-------------------------------------------------------------------------------}
runIO :: LockstepAction FsState a -> LookUp -> RealMonad a
runIO action lookUp = ReaderT $ \root -> aux root action
where
aux :: FilePath -> LockstepAction FsState a -> IO a
aux root = \case
MkDir d -> catchErr $
IO.createDirectory (Mock.dirFP root d)
Open f -> catchErr $
(,f) <$> IO.openFile (Mock.fileFP root f) IO.AppendMode
Write h s -> catchErr $
IO.hPutStr (lookUp' h) s
Close h -> catchErr $
IO.hClose (lookUp' h)
Read f -> catchErr $ do
fault <- getFaultRef
s <- IO.readFile (Mock.fileFP root $ either lookUp' id f)
case fault of
Fault | length s >= 3 -> pure ""
_ -> pure s
where
lookUp' :: FsVar x -> x
lookUp' = realLookupVar lookUp
catchErr :: forall a. IO a -> IO (Either Err a)
catchErr act = catch (Right <$> act) handler
where
handler :: IOError -> IO (Either Err h)
handler e = maybe (throwIO e) (return . Left) (Mock.fromIOError e)
{-------------------------------------------------------------------------------
Statistics and tagging
-------------------------------------------------------------------------------}
-- The only statistics we need to track for this example is the files we opened
type Stats = Set File
initStats :: Stats
initStats = Set.empty
updateStats :: LockstepAction FsState a -> FsVal a -> Stats -> Stats
updateStats action result =
case (action, result) of
(Open f, MEither (Right _)) -> Set.insert f
_otherwise -> id
data Tag = OpenTwo | SuccessfulRead
deriving stock (Show)
tagFsAction :: Stats -> LockstepAction FsState a -> FsVal a -> [Tag]
tagFsAction openedFiles = \case
Read _ -> \v -> [SuccessfulRead | MEither (Right _) <- [v]]
Open _ -> \_ -> [OpenTwo | Set.size openedFiles >= 2]
_ -> \_ -> []
{-------------------------------------------------------------------------------
Top-level test
-------------------------------------------------------------------------------}
tests :: TestTree
tests = testGroup "Test.MockFS" [
testCase "labelledExamples" $
QC.labelledExamples $ Lockstep.tagActions (Proxy @FsState)
, testProperty "propLockstep" propLockstep
]
propLockstep :: Actions (Lockstep FsState) -> QC.Property
propLockstep =
Lockstep.runActionsBracket (Proxy @FsState)
(createSystemTempDirectory "QSM")
removeDirectoryRecursive
runReaderT
createSystemTempDirectory :: [Char] -> IO FilePath
createSystemTempDirectory prefix = do
systemTempDir <- getCanonicalTemporaryDirectory
createTempDirectory systemTempDir prefix
{-------------------------------------------------------------------------------
Unsafe: induce test failure
-------------------------------------------------------------------------------}
data Fault = Fault | NoFault
deriving stock Eq
{-# NOINLINE faultRef #-}
-- | A mutable variable that can be set globally to induce test failures in
-- 'propLockstep'. This is used in "Test.Golden" to golden test counterexamples
-- as produced by the @quickcheck-lockstep@.
faultRef :: IORef Fault
faultRef = unsafePerformIO $ newIORef NoFault
{-# NOINLINE getFaultRef #-}
getFaultRef :: IO Fault
getFaultRef = readIORef faultRef
{-# NOINLINE setFaultRef #-}
setFaultRef :: Fault -> IO ()
setFaultRef = writeIORef faultRef
{-# NOINLINE setInduceFault #-}
setInduceFault :: IO ()
setInduceFault = setFaultRef Fault
{-# NOINLINE setNoInduceFault #-}
setNoInduceFault :: IO ()
setNoInduceFault = setFaultRef NoFault
{-------------------------------------------------------------------------------
Unsafe: set postcondition
-------------------------------------------------------------------------------}
data Postcondition =
DefaultPostcondition
| NonVerbosePostcondition
| VerbosePostcondition
runPostcondition ::
Postcondition
-> (Lockstep FsState, Lockstep FsState)
-> Action (Lockstep FsState) a
-> LookUp
-> a
-> PostconditionM RealMonad Bool
runPostcondition DefaultPostcondition = Lockstep.postcondition
runPostcondition NonVerbosePostcondition = Lockstep.postconditionWith False
runPostcondition VerbosePostcondition = Lockstep.postconditionWith True
{-# NOINLINE postconditionRef #-}
postconditionRef :: IORef Postcondition
postconditionRef = unsafePerformIO $ newIORef DefaultPostcondition
{-# NOINLINE getPostcondition #-}
getPostcondition :: IO Postcondition
getPostcondition = readIORef postconditionRef
{-# NOINLINE setPostconditionDefault #-}
setPostconditionDefault :: IO ()
setPostconditionDefault = setPostcondition VerbosePostcondition
{-# NOINLINE setPostconditionVerbose #-}
setPostconditionVerbose :: IO ()
setPostconditionVerbose = setPostcondition VerbosePostcondition
{-# NOINLINE setPostconditionNonVerbose #-}
setPostconditionNonVerbose :: IO ()
setPostconditionNonVerbose = setPostcondition NonVerbosePostcondition
{-# NOINLINE setPostcondition #-}
setPostcondition :: Postcondition -> IO ()
setPostcondition = writeIORef postconditionRef