-- | This module provides functions and a TestControl effect for implmenting
-- unit tests with 'Control.Monad.Freer.Converse'.

module Control.Monad.Freer.TestControl
  ( TestControl
  , runTestControl
  , runTestControlData
  , runTestControlData_
  , runTestControlError
  , TestExitStatus(..)

      -- * Controlling the test
  , fulfilled
  , throwUnexpected
  , throwExpecting
  , failure

      -- * Interacting with the test subject
  , expect
  , collect
  , stub
  , stubs
  , result
  , result_
  , converse

      -- * Arguments to 'expect', 'stubs', etc
  , spy

  ) where

import           Control.Applicative
import           Control.Arrow                                 (first)
import           Control.Monad                                 (join)
import           Control.Monad.Freer.Converse
import           Control.Monad.Freer.Exception
import           Data.Monoid
import           Data.Functor.Classes.FreerConverse.Parametric

-- | An effect for terminating the test when either the test has failed, or the
-- goal of the test has been fulfilled without problems but need not continue
-- the normal flow of execution.
type TestControl = Exc TestExitStatus
-- | Interruption of a test run.
data TestExitStatus = TestFulfilled -- ^ The goal of the test was accomplished and the test need not continue.
                    | TestFailed String -- ^ A problem was detected

-- | The goal of the test has been accomplished. Stops further execution of the
-- test. Results in a successful test result.
fulfilled :: Member TestControl r => Eff r a
fulfilled = throwError TestFulfilled

-- | Handler for 'TestControl' effects. Runs the computation (a test) and
--
--   * calls into the first argument on failure,
--   * calls into the second argument on 'fulfilled' or
--   * returns the value produced by the test (often just '()').
--
-- Note that the @r@ parameter determines what (computational or I/O)
-- effects are required/allowed for running the test. This makes it
-- possible to write pure tests, tests that explore all branches of
-- nondeterministic choices, tests that read from files dynamically,
-- etc.
runTestControl :: (String -> Eff r a) -- ^ On failure
               -> Eff r a             -- ^ On fulfill
               -> Eff (TestControl ': r) a -- ^ The test, with @TestControl@
               -> Eff r a             -- ^ The test, without @TestControl@
runTestControl onFail onFulfill t = runError t >>= \testResult -> case testResult of
  Left (TestFailed s) -> onFail s
  Left TestFulfilled  -> onFulfill
  Right x             -> return x

-- | Runs a test, letting it terminate early, as appropriate.
--
-- Like 'runTestControl' but for those who like to pattern match instead.
runTestControlData :: Eff (TestControl ': r) a -> Eff r (Either String (Maybe a))
runTestControlData a = runTestControl (return . Left) (return (Right Nothing)) (fmap (Right . Just) a)

-- | Runs a test, letting it terminate early, as appropriate.
--
-- Like 'runTestControlData' but will not return a value from the test.
runTestControlData_ :: Eff (TestControl ': r) a -> Eff r (Either String ())
runTestControlData_ a = runTestControl (return . Left) (return (Right ())) (Right () <$ a)

-- | Runs a test, letting it terminate early, as appropriate.
--
-- Throws an error with 'error' on failure.
runTestControlError :: Eff (TestControl ': r) () -> Eff r ()
runTestControlError = runTestControl (error . showString "Test failed: ") (return ())

-- | Terminates the test with error, showing provided reason and next event.
failure :: (Member TestControl r, Show v, ShowP f) => String -- ^ Reason for test failure
  -> Eff (Converse f r v ': r) a
failure reason = do
  nextEvent <- showNext
  throwError $ TestFailed $ reason ++ "\nNext event: " ++ nextEvent

-- | Terminates test as a failure by showing the expectation and the event.
throwExpecting
  :: ( ShowP f
     , Member TestControl r
     )
  => String -- ^ Noun phrase describing expectation
  -> f a -- ^ Unexpected event
  -> Eff r b
throwExpecting expectation v = throwError $ TestFailed $ "Expecting " ++ expectation ++ ", but got " ++ showP v

-- | Throw an unexpected event error
throwUnexpected :: (ShowP f, Member TestControl r) => f a -> Eff r b
throwUnexpected v = throwError $ TestFailed $ "Unexpected effect: " ++ showP v

-- | When an event occurs, provide a value @a@ for the test subject and a value @b@
-- for the test script.
expect
  :: Member TestControl r
  => (forall a. f a -> Eff r (a, b))
  -> Eff (Converse f r v ': r) b
expect f = converse (\x -> first Just <$> f x) (const $ throwError $ TestFailed "Unexpected program termination: effect expected.")

-- | When an event occurs, provide a value to the test subject.
--
-- Like 'expect', but does not return a value to the test script.
stub :: Member TestControl r
  => (forall b. f b -> Eff r b)
  -> Eff (Converse f r v ': r) ()
stub f = expect (fmap (\x -> (x,())) <$> f)

-- | Provide a value to the test subject, if and as long as matching
-- events occur. Matching stops when Nothing is returned from the passed function.
--
-- Returns the number of events that have been matched.
collect :: (forall a. f a -> Eff r (Maybe (a, b))) -> Eff (Converse f r v ': r) [b]
collect f = do
  join $ converse (
            \x -> do
              replyMaybe <- f x
              case replyMaybe of
                Just (reply, spied) -> return (Just reply, (spied :) <$> collect f)
                Nothing -> return (Nothing, (return []))
            ) (const $ return (return []))

-- | Like 'collect', but simpler because it does not return a value to
-- the test script.
stubs
  :: (forall b. f b -> Eff r (Maybe b))
  -> Eff (Converse f r v ': r) ()
stubs f = do
  join $ converse (
            \x -> do
              replyMaybe <- f x
              case replyMaybe of
                Just reply -> return (Just reply, stubs f)
                Nothing    -> return (Nothing, (return ()))
            ) (const $ return (return ()))

-- | Retrieve the result of the program. Fails if an effect of type
-- @f@ is still pending.
result :: (Member TestControl r, ShowP f) => Eff (Converse f r v ': r) v
result = converse throwUnexpected return

-- | Like 'result' but more generic because it does not attempt to
-- show the unexpected effect in the error message.
result_ :: (Member TestControl r) => Eff (Converse f r v ': r) v
result_ = converse (const $ throwError $ TestFailed $
                      "Expected program termination with result, but got an effect instead."
                   )
                   return



-- | Provide empty response to test subject, pass argument to test script
spy :: (Monad m, Monoid mm) => a -> m (mm, a)
spy a = return (mempty, a)