packages feed

mockcat-1.3.3.0: src/Test/MockCat/WithMock.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -Wno-name-shadowing #-}

{- | withMock: Declarative mock expectations DSL
-}
module Test.MockCat.WithMock
  ( withMock
  , withMockIO
  , askWithMockContext
  , expects
  , MockResult(..)
  , called
  , with
  , calledInOrder
  , calledInSequence
  , times
  , once
  , never
  , atLeast
  , atMost
  , greaterThan
  , lessThan
  , anything
  , WithMockContext(..)
  , Expectation(..)
  , Expectations(..)
  , verifyExpectationDirect
  ) where

import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Reader (ReaderT(..), runReaderT)
import Control.Exception (bracket_)
import Control.Concurrent.STM (newTVarIO, readTVarIO, atomically, modifyTVar')
import Control.Monad.State (get, put, modify)
import Test.MockCat.Verify (TimesSpec(..), times, once, never, atLeast, atMost, greaterThan, lessThan, anything, ResolvableMock, ResolvableParamsOf)
import Test.MockCat.Internal.Verify (verifyExpectationDirect)
import Test.MockCat.Internal.Types
  ( VerifyOrderMethod(..)
  , WithMockContext(..)
  , Expectation(..)
  , Expectations(..)
  , runExpectations
  , addExpectation
  , InvocationRecorder(..)
  , ResolvedMock(..)
  )
import qualified Test.MockCat.Internal.Registry.Core as MockRegistry
import Test.MockCat.Internal.Registry.Core (getThreadWithMockContext, setThreadWithMockContext, clearThreadWithMockContext)
import Unsafe.Coerce (unsafeCoerce)

import Test.MockCat.Param (Param(..), param, EqParams(..))
import Data.Kind (Type)
import Data.Proxy (Proxy(..))

-- | A specialized Unit type that carries the parameter type information.
--   This is used to improve type inference for unit-returning mock helpers.
newtype MockResult params = MockResult ()
  deriving (Show, Eq)



-- | Run a block with mock expectations that are automatically verified
withMock :: ReaderT WithMockContext IO a -> IO a
withMock action = do
  ctxVar <- newTVarIO []
  let ctx = WithMockContext ctxVar
  bracket_ (setThreadWithMockContext ctx) clearThreadWithMockContext $ do
    result <- runReaderT action ctx
    -- Verify all registered verification actions
    actions <- readTVarIO ctxVar
    sequence_ actions
    pure result

-- | IO version of withMock
withMockIO :: IO a -> IO a
withMockIO action = do
  ctxVar <- newTVarIO []
  let ctx = WithMockContext ctxVar
  bracket_ (setThreadWithMockContext ctx) clearThreadWithMockContext $ do
    result <- action
    -- Verify all registered verification actions
    actions <- readTVarIO ctxVar
    sequence_ actions
    pure result

-- | Retrieve the current mock context from thread-local storage.
--   Throws an error if no context is found.
askWithMockContext :: IO WithMockContext
askWithMockContext = do
  mCtx <- getThreadWithMockContext
  case mCtx of
    Just ctx -> pure ctx
    Nothing -> errorWithoutStackTrace "askWithMockContext: No WithMockContext found in current thread. Use withMock or withMockIO."

-- | Attach expectations to a mock function
--   Supports both single expectation and multiple expectations in a do block
infixl 0 `expects`

-- | Type class to extract params type from an expectation expression
class ExtractParams exp where
  type ExpParams exp :: Type
  extractParams :: exp -> Proxy (ExpParams exp)

instance ExtractParams (Expectations params ()) where
  type ExpParams (Expectations params ()) = params
  extractParams _ = Proxy

instance ExtractParams (fn -> Expectations params ()) where
  type ExpParams (fn -> Expectations params ()) = params
  extractParams _ = Proxy

-- | Register expectations for a mock function
--   Accepts an Expectations builder
--   The params type is usually inferred from the mock function, but can be overridden
class BuildExpectations fn exp params | fn exp -> params where
  buildExpectations :: fn -> exp -> [Expectation params]

-- | Instance for direct Expectations value
instance {-# OVERLAPPABLE #-} forall fn params. (ResolvableParamsOf fn ~ params) => BuildExpectations fn (Expectations params ()) params where
  buildExpectations _ = runExpectations

-- | Instance for function form when fn is MockResult
instance {-# OVERLAPPING #-} forall params. BuildExpectations (MockResult params) (MockResult params -> Expectations params ()) params where
  buildExpectations _ f = runExpectations (f (MockResult ()))

-- | Instance for direct Expectations value when fn is MockResult
instance {-# OVERLAPPING #-} forall params. BuildExpectations (MockResult params) (Expectations params ()) params where
  buildExpectations _ = runExpectations

-- | Instance for direct Expectations value when fn is ()
instance {-# OVERLAPPING #-} forall params. BuildExpectations () (Expectations params ()) params where
  buildExpectations _ = runExpectations

-- | Instance for function form (fn -> Expectations params ())
instance {-# OVERLAPPABLE #-} forall fn params. (ResolvableParamsOf fn ~ params) => BuildExpectations fn (fn -> Expectations params ()) params where
  buildExpectations fn f = runExpectations (f fn)

-- | Type class for dispatching expectations based on mock function type
class ExpectsDispatch fn exp m where
  expects :: m fn -> exp -> m fn

-- | Specialized instance for MockResult helpers (handles type inference)
instance {-# OVERLAPPING #-} (exp ~ Expectations params (), ExpectsDispatchImpl 'True (MockResult params) (Expectations params ()) m) => ExpectsDispatch (MockResult params) exp m where
  expects = expectsDispatchImpl @'True

-- | Specialized instance for Unit helpers (fallback)
instance {-# OVERLAPPING #-} (exp ~ Expectations params (), ExpectsDispatchImpl 'True () (Expectations params ()) m) => ExpectsDispatch () exp m where
  expects = expectsDispatchImpl @'True

-- | Generic instance for normal mocks
instance {-# OVERLAPPABLE #-} (ExpectsDispatchImpl 'False fn exp m) => ExpectsDispatch fn exp m where
  expects = expectsDispatchImpl @'False

-- | Internal class for implementation dispatch
class ExpectsDispatchImpl (flag :: Bool) fn exp m where
  expectsDispatchImpl :: m fn -> exp -> m fn

-- | Instance for normal mocks (flag ~ 'False)
--   Strict matching of params
instance
  ( MonadIO m
  , ResolvableMock fn
  , ResolvableParamsOf fn ~ params
  , ExtractParams exp
  , ExpParams exp ~ params
  , BuildExpectations fn exp params
  , Show params
  , EqParams params
  ) =>
  ExpectsDispatchImpl 'False fn exp m
  where
  expectsDispatchImpl mockFnM exp = do
    WithMockContext ctxVar <- liftIO askWithMockContext
    -- Try to help type inference by using exp first
    let _ = extractParams exp :: Proxy params
    mockFn <- mockFnM
    -- Get the recorder from the thread-local store (set by mock/register)
    -- This avoids StableName lookup and is HPC-safe
    (mockName, mRecorder) <- liftIO $ MockRegistry.getLastRecorder @(InvocationRecorder params)

    let resolved = case mRecorder of
          Just recorder -> ResolvedMock mockName recorder
          Nothing -> errorWithoutStackTrace "expects: mock recorder not found. Use mock inside withMock/runMockT."

    let expectations = buildExpectations mockFn exp
    let actions = map (verifyExpectationDirect resolved) expectations
    liftIO $ atomically $ modifyTVar' ctxVar (++ actions)
    pure mockFn

-- | Instance for MockResult mocks (flag ~ 'True)
--   Dynamic resolution using expectation params
instance
  ( MonadIO m
  , BuildExpectations (MockResult params) (Expectations params ()) params
  , Show params
  , EqParams params
  ) =>
  ExpectsDispatchImpl 'True (MockResult params) (Expectations params ()) m
  where
  expectsDispatchImpl mockFnM exp = do
    WithMockContext ctxVar <- liftIO askWithMockContext
    _ <- mockFnM
    (mockName, mRecorder) <- liftIO MockRegistry.getLastRecorderRaw
    resolved <- case mRecorder of
      Just raw -> do
         let recorder = unsafeCoerce raw :: InvocationRecorder params
         pure $ ResolvedMock mockName recorder
      Nothing -> errorWithoutStackTrace "expects: mock recorder not found (Dynamic Resolution Failed). Ensure the mock helper function was called."
    -- Use the expectations directly since we know the context
    let expectations = runExpectations exp
    let actions = map (verifyExpectationDirect resolved) expectations
    liftIO $ atomically $ modifyTVar' ctxVar (++ actions)
    pure (MockResult ())

-- | Instance for Unit mocks (flag ~ 'True)
--   Dynamic resolution using expectation params
instance
  ( MonadIO m
  , BuildExpectations () (Expectations params ()) params
  , Show params
  , EqParams params
  ) =>
  ExpectsDispatchImpl 'True () (Expectations params ()) m
  where
  expectsDispatchImpl mockFnM exp = do
    WithMockContext ctxVar <- liftIO askWithMockContext
    _ <- mockFnM
    (mockName, mRecorder) <- liftIO MockRegistry.getLastRecorderRaw
    resolved <- case mRecorder of
      Just raw -> do
         let recorder = unsafeCoerce raw :: InvocationRecorder params
         pure $ ResolvedMock mockName recorder
      Nothing -> errorWithoutStackTrace "expects: mock recorder not found (Dynamic Resolution Failed). Ensure the mock helper function was called."
    let expectations = buildExpectations () exp
    let actions = map (verifyExpectationDirect resolved) expectations
    liftIO $ atomically $ modifyTVar' ctxVar (++ actions)
    pure ()



-- | Create a count expectation builder
--   The params type is inferred from the mock function in expects
--   Use type application to specify params when needed: called @(Param String) once
-- | Class-based called builder so that the `params` type can be resolved
--   via instance selection in the `expects` context.
--   This version uses a type class to help type inference by allowing
--   the params type to be inferred from the context where it's used.
class Called params where
  called :: TimesSpec -> Expectations params ()

-- | Default instance that works for any params type
instance {-# OVERLAPPABLE #-} Called params where
  called (TimesSpec method) = do
    addExpectation (CountAnyExpectation method)

-- | Combine expectations with arguments
--   Accepts both raw values (like "a") and Param values (like param "a")
class WithArgs exp args params | exp args -> params where
  with :: exp -> args -> Expectations params ()

instance {-# OVERLAPPING #-}
  WithArgs (Expectations params ()) params params
  where
  with expM args = do
    expM
    -- Extract the last expectation (last in list, since addExpectation appends) and modify it to include args
    Expectations $ do
      exps <- get
      case reverse exps of
        [] -> error "with: no expectation to add arguments to"
        (CountAnyExpectation method : rest) -> do
          put (reverse rest)
          modify (++ [CountExpectation method args])
        _ -> error "with: can only add arguments to count-only expectations"

instance {-# OVERLAPPABLE #-}
  (params ~ Param a, Show a, Eq a) =>
  WithArgs (Expectations params ()) a params
  where
  with expM rawValue = do
    expM
    -- Extract the last expectation (last in list, since addExpectation appends) and modify it to include args
    Expectations $ do
      exps <- get
      case reverse exps of
        [] -> error "with: no expectation to add arguments to"
        (CountAnyExpectation method : rest) -> do
          put (reverse rest)
          modify (++ [CountExpectation method (param rawValue)])
        _ -> error "with: can only add arguments to count-only expectations"




-- | Create an order expectation
--   Accepts both Param values and raw values
class CalledInOrder args params | args -> params where
  calledInOrder :: args -> Expectations params ()

-- | Convenience instance: infer params from function argument type @a@
instance
  (params ~ Param a, Show a, Eq a) =>
  CalledInOrder [a] params
  where
  calledInOrder args =
    addExpectation (OrderExpectation ExactlySequence (map param args))

-- | Create a partial order expectation
--   Accepts both Param values and raw values
class CalledInSequence args params | args -> params where
  calledInSequence :: args -> Expectations params ()

-- | Convenience instance: infer params from function argument type @a@
instance
  (params ~ Param a, Show a, Eq a) =>
  CalledInSequence [a] params
  where
  calledInSequence args =
    addExpectation (OrderExpectation PartiallySequence (map param args))