mockcat-1.4.0.0: src/Test/MockCat/Internal/Builder.hs
{-# LANGUAGE FlexibleContexts #-}
{-# OPTIONS_GHC -Wno-missing-export-lists #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Use null" #-}
{-# OPTIONS_GHC -Wno-name-shadowing #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE TypeApplications #-}
module Test.MockCat.Internal.Builder where
import Control.Concurrent.STM
( TVar
, atomically
, modifyTVar'
, newTVarIO
, readTVar
, readTVarIO
, writeTVar
)
import Data.Maybe
import Test.MockCat.Cons (Head(..), (:>)(..))
import Test.MockCat.Param
import Prelude hiding (lookup)
import Control.Monad.State
import Test.MockCat.Internal.Types
import Test.MockCat.Internal.Message
-- | Class for building a curried function.
-- The purpose of this class is to automatically generate and provide
-- an implementation for the corresponding curried function type (such as `a -> b -> ... -> IO r`)
-- when given the argument list type of the mock (`Param a :> Param b :> ...`).
-- @args@ is the argument list type of the mock.
-- @r@ is the return type of the function.
-- @fn@ is the curried function type.
class BuildCurried args r fn | args r -> fn where
buildCurriedImpl :: (args -> IO r) -> fn
-- | Build a curried function that returns an IO result.
buildCurried :: forall args r fn. BuildCurried args r fn => (args -> IO r) -> fn
buildCurried = buildCurriedImpl
instance (ToParamArg a, Normalize a ~ Param a, fn ~ (a -> r)) => BuildCurried (Param a) r fn where
buildCurriedImpl f a = perform (f (toParamArg a))
instance
( BuildCurried rest r fn
, ToParamArg a
, Normalize a ~ Param a
, fn' ~ (a -> fn)
) =>
BuildCurried (Param a :> rest) r fn'
where
buildCurriedImpl input a =
buildCurriedImpl @rest @r @fn (input . (\rest -> toParamArg a :> rest))
-- | Class for building a curried pure function without relying on IO.
class BuildCurriedPure args r fn | args r -> fn where
buildCurriedPureImpl :: (args -> r) -> fn
-- | Build a curried function that returns a pure result.
buildCurriedPure :: forall args r fn. BuildCurriedPure args r fn => (args -> r) -> fn
buildCurriedPure = buildCurriedPureImpl
instance (ToParamArg a, Normalize a ~ Param a, fn ~ (a -> r)) => BuildCurriedPure (Param a) r fn where
buildCurriedPureImpl f a = f (toParamArg a)
instance
( BuildCurriedPure rest r fn
, ToParamArg a
, Normalize a ~ Param a
, fn' ~ (a -> fn)
) =>
BuildCurriedPure (Param a :> rest) r fn'
where
buildCurriedPureImpl input a =
buildCurriedPureImpl @rest @r @fn (input . (\rest -> toParamArg a :> rest))
-- | Class for building a curried function whose result stays in IO.
class BuildCurriedIO args r fn | args r -> fn where
buildCurriedIOImpl :: (args -> IO r) -> fn
-- | Build a curried IO function without hiding the IO layer.
buildCurriedIO :: forall args r fn. BuildCurriedIO args r fn => (args -> IO r) -> fn
buildCurriedIO = buildCurriedIOImpl
instance (ToParamArg a, Normalize a ~ Param a, fn ~ (a -> IO r)) => BuildCurriedIO (Param a) r fn where
buildCurriedIOImpl f a = f (toParamArg a)
instance
( BuildCurriedIO rest r fn
, ToParamArg a
, Normalize a ~ Param a
, fn' ~ (a -> fn)
) =>
BuildCurriedIO (Param a :> rest) r fn'
where
buildCurriedIOImpl input a =
buildCurriedIOImpl @rest @r @fn (input . (\rest -> toParamArg a :> rest))
-- | Class for creating a stub corresponding to the parameter description.
class MockBuilder params fn verifyParams | params -> fn, params -> verifyParams where
build ::
MonadIO m =>
Maybe MockName ->
params ->
m (BuiltMock fn verifyParams)
-- | New name for `build` to make intent explicit.
-- `buildMock` constructs a mock function and its verifier.
buildMock ::
( MonadIO m
, MockBuilder params fn verifyParams
) =>
Maybe MockName ->
params ->
m (BuiltMock fn verifyParams)
buildMock = build
-- | Instance for building a stub for a constant IO action.
instance
MockBuilder (IO r) (IO r) ()
where
build _ action = do
ref <- liftIO $ newTVarIO invocationRecord
let
fn = do
result <- action
liftIO $ appendCalledParams ref ()
pure result
recorder = InvocationRecorder ref IOConstant
pure (BuiltMock fn recorder)
-- | Instance for building a stub for a constant value (with Head marker).
instance
MockBuilder (Head :> Param r) r ()
where
build _ (Head :> params) = do
ref <- liftIO $ newTVarIO invocationRecord
let v = value params
fn = perform $ do
liftIO $ appendCalledParams ref ()
pure v
recorder = InvocationRecorder ref PureConstant
pure (BuiltMock fn recorder)
-- | Instance for building a stub for `Cases (IO a) ()`.
instance MockBuilder (Cases (IO a) ()) (IO a) () where
build _ cases = do
let params = runCase cases
ref <- liftIO $ newTVarIO invocationRecord
let fn = do
count <- readInvocationCount ref ()
let index = min count (length params - 1)
r = safeIndex params index
appendCalledParams ref ()
incrementInvocationCount ref ()
fromJust r
recorder = InvocationRecorder ref IOConstant
pure (BuiltMock fn recorder)
-- | Overlapping instance for building a stub when parameters are provided as 'Cases'.
instance {-# OVERLAPPABLE #-}
( ParamConstraints params args r
, BuildCurried args r fn
) => MockBuilder (Cases params ()) fn args where
build name cases = do
let paramsList = runCase cases
buildWithRecorder (\ref inputParams -> executeInvocation ref (casesInvocationStep name paramsList inputParams))
-- | Overlapping instance for building a stub defined via chained 'Param'.
instance {-# OVERLAPPABLE #-}
( p ~ (Param a :> rest)
, ParamConstraints p args r
, BuildCurried args r fn
) => MockBuilder (Param a :> rest) fn args where
build name params =
buildWithRecorder (\ref inputParams -> executeInvocation ref (singleInvocationStep name params inputParams))
-- | Class for building mocks whose resulting functions stay in IO.
class MockIOBuilder params fn verifyParams | params -> fn, params -> verifyParams where
buildIO ::
MonadIO m =>
Maybe MockName ->
params ->
m (BuiltMock fn verifyParams)
instance {-# OVERLAPPABLE #-}
( ParamConstraints params args r
, BuildCurriedIO args r fn
) => MockIOBuilder (Cases params ()) fn args where
buildIO name cases = do
let paramsList = runCase cases
buildWithRecorderIO (\ref inputParams -> executeInvocation ref (casesInvocationStep name paramsList inputParams))
instance {-# OVERLAPPABLE #-}
( p ~ (Param a :> rest)
, ParamConstraints p args r
, BuildCurriedIO args r fn
) => MockIOBuilder (Param a :> rest) fn args where
buildIO name params =
buildWithRecorderIO (\ref inputParams -> executeInvocation ref (singleInvocationStep name params inputParams))
buildWithRecorder ::
( MonadIO m
, BuildCurried args r fn
) =>
(TVar (InvocationRecord args) -> args -> IO r) ->
m (BuiltMock fn args)
buildWithRecorder handler = do
ref <- liftIO $ newTVarIO invocationRecord
let fn = buildCurried (handler ref)
recorder = InvocationRecorder ref ParametricFunction
pure (BuiltMock fn recorder)
buildWithRecorderIO ::
( MonadIO m
, BuildCurriedIO args r fn
) =>
(TVar (InvocationRecord args) -> args -> IO r) ->
m (BuiltMock fn args)
buildWithRecorderIO handler = do
ref <- liftIO $ newTVarIO invocationRecord
let fn = buildCurriedIO (handler ref)
recorder = InvocationRecorder ref ParametricFunction
pure (BuiltMock fn recorder)
invocationRecord :: InvocationRecord params
invocationRecord =
InvocationRecord
{ invocations = mempty
, invocationCounts = []
}
appendCalledParams :: TVar (InvocationRecord params) -> params -> IO ()
appendCalledParams ref inputParams =
atomically $
modifyTVar' ref $ \record ->
record
{ invocations = invocations record ++ [inputParams]
}
readInvocationCount :: EqParams params => TVar (InvocationRecord params) -> params -> IO Int
readInvocationCount ref params = do
record <- readTVarIO ref
pure $ fromMaybe 0 (lookupEqParams params (invocationCounts record))
incrementInvocationCount :: EqParams params => TVar (InvocationRecord params) -> params -> IO ()
incrementInvocationCount ref inputParams =
atomically $
modifyTVar' ref $ \record ->
record
{ invocationCounts = incrementCountEqParams inputParams (invocationCounts record)
}
runCase :: Cases a b -> [a]
runCase (Cases s) = execState s []
p :: (Show a, Eq a) => a -> Param a
p v = ExpectValue v (show v)
class StubBuilder params fn | params -> fn where
buildStub :: Maybe MockName -> params -> fn
-- | Instance for building a mock for a constant function.
instance
StubBuilder (IO r) (IO r)
where
buildStub _ = id
-- | Instance for building a mock for a function with a single parameter.
instance
StubBuilder (Param r) r
where
buildStub _ = value
-- | Instance for building a mock for a function with multiple parameters.
instance StubBuilder (Cases (IO a) ()) (IO a) where
buildStub _ cases = do
let params = runCase cases
s <- liftIO $ newTVarIO invocationRecord
(do
count <- readInvocationCount s ()
let index = min count (length params - 1)
r = safeIndex params index
appendCalledParams s ()
incrementInvocationCount s ()
fromJust r)
-- | Overlapping instance for building a mock for a function with multiple parameters.
-- This instance is used when the parameter type is a 'Cases' type.
instance {-# OVERLAPPABLE #-}
( ParamConstraints params args r
, BuildCurried args r fn
, BuildCurriedPure args r fn
) => StubBuilder (Cases params ()) fn where
buildStub name cases = do
let paramsList = runCase cases
buildCurriedPure (findReturnValueWithPure name paramsList)
instance {-# OVERLAPPABLE #-}
( p ~ (Param a :> rest)
, ParamConstraints p args r
, BuildCurried args r fn
, BuildCurriedPure args r fn
) => StubBuilder (Param a :> rest) fn where
buildStub name params = buildCurriedPure (extractReturnValue name params)
type ParamConstraints params args r =
( ProjectionArgs params
, ProjectionReturn params
, ArgsOf params ~ args
, ReturnOf params ~ Param r
, EqParams args
, Show args
)
extractReturnValue :: ParamConstraints params args r => Maybe MockName -> params -> args -> r
extractReturnValue name params inputParams = do
validateOnly name (projArgs params) inputParams `seq` returnValue params
validateOnly :: (EqParams a, Show a) => Maybe MockName -> a -> a -> ()
validateOnly name expected actual = do
validateParamsPure name expected actual
validateParamsPure :: (EqParams a, Show a) => Maybe MockName -> a -> a -> ()
validateParamsPure name expected actual =
if expected `eqParams` actual
then ()
else errorWithoutStackTrace $ message name expected actual
findReturnValueWithPure ::
( ParamConstraints params args r
) =>
Maybe MockName ->
InvocationList params ->
args ->
r
findReturnValueWithPure name paramsList inputParams = do
let
expectedArgs = projArgs <$> paramsList
r = findReturnValuePure paramsList inputParams
fromMaybe (errorWithoutStackTrace $ messageForMultiMock name expectedArgs inputParams) r
findReturnValuePure ::
( ParamConstraints params args r
) =>
InvocationList params ->
args ->
Maybe r
findReturnValuePure paramsList inputParams = do
let matchedParams = filter (\params -> projArgs params `eqParams` inputParams) paramsList
case matchedParams of
[] -> Nothing
_ -> do
returnValue <$> safeIndex matchedParams 0
type InvocationStep args r = InvocationRecord args -> (InvocationRecord args, Either Message r)
executeInvocation ::
TVar (InvocationRecord args) ->
InvocationStep args r ->
IO r
executeInvocation ref step = do
result <-
atomically $ do
current <- readTVar ref
let (next, outcome) = step current
writeTVar ref next
pure outcome
either errorWithoutStackTrace pure result
singleInvocationStep ::
ParamConstraints params args r =>
Maybe MockName ->
params ->
args ->
InvocationStep args r
singleInvocationStep name params inputParams record@InvocationRecord {invocations, invocationCounts} = do
let expected = projArgs params
if expected `eqParams` inputParams
then
(InvocationRecord {
invocations = invocations ++ [inputParams]
, invocationCounts = invocationCounts
}, Right (returnValue params))
else (record, Left $ message name expected inputParams)
casesInvocationStep ::
ParamConstraints params args r =>
Maybe MockName ->
InvocationList params ->
args ->
InvocationStep args r
casesInvocationStep name paramsList inputParams InvocationRecord {invocations, invocationCounts} = do
let newInvocations = invocations ++ [inputParams]
matchedParams = filter (\params -> projArgs params `eqParams` inputParams) paramsList
expectedArgs = projArgs <$> paramsList
in case matchedParams of
[] ->
( InvocationRecord {invocations = newInvocations, invocationCounts},
Left (messageForMultiMock name expectedArgs inputParams)
)
_ ->
let calledCount = fromMaybe 0 (lookupEqParams inputParams invocationCounts)
index = min calledCount (length matchedParams - 1)
nextCounter = incrementCountEqParams inputParams invocationCounts
nextRecord =
InvocationRecord
{ invocations = newInvocations,
invocationCounts = nextCounter
}
in case safeIndex matchedParams index of
Nothing ->
( nextRecord,
Left (messageForMultiMock name expectedArgs inputParams)
)
Just selected ->
(nextRecord, Right (returnValue selected))
lookupEqParams :: EqParams k => k -> [(k, v)] -> Maybe v
lookupEqParams _ [] = Nothing
lookupEqParams k ((x,y):xs)
| eqParams k x = Just y
| otherwise = lookupEqParams k xs
incrementCountEqParams :: (EqParams k, Num v) => k -> [(k, v)] -> [(k, v)]
incrementCountEqParams k [] = [(k, 1)]
incrementCountEqParams k ((x,y):xs)
| eqParams k x = (x, y + 1) : xs
| otherwise = (x, y) : incrementCountEqParams k xs