{-# LANGUAGE GeneralizedNewtypeDeriving, MultiParamTypeClasses, TemplateHaskell, FlexibleInstances #-}
----------------------------------------------------------------------
-- |
-- Module      :  Test.Simple
-- Copyright   :  (c) Boris Sukholitko 2012
-- License     :  BSD3
-- 
-- Maintainer  :  boriss@gmail.com
-- Stability   :  experimental
-- 
-- Test.Simple is yet another testing library for Haskell. It has testing primitives
-- familiar to recovering Perl programmers :).
-- 
-- Having 'MonadPlus' instance allows to cut tests early e.g using 'guard' function.
--
-- Being monad transformer it includes integration with 'QuickCheck' by declaring 'Testable' instance 
-- on @TestSimpleT Gen a@.
-- 
-- Test.Simple also has the ability to run in pure context (see 'runTestSimple' function).
--
-- Here is an example suitable for cabal test-suite integration. Note that TemplateHaskell
-- usage is optional and is needed for test failure locations only.
--
-- @
--{-\# LANGUAGE TemplateHaskell \#-}
--
--import Test.Simple
--import Control.Monad
--
--main :: IO ()
--main = testSimpleMain $ do
--          plan 7
--          ok True
--          is 1 1
--          isnt \"a\" \"b\"
--          like \"abcd\" \"bc\"
--          unlike \"a\" \"b\"
--          diag \"Successful so far, failures follow ...\"
--          $loc >> ok False \-\- location will be recorded
--          is \"a\" \"b\" >>= guard
--          diag \"I am not being called\" \-\- not reached because of the guard: MonadPlus FTW!
-- @
--
----------------------------------------------------------------------

module Test.Simple (
            -- * Types
            TestSimpleT, Likeable(isLike),
            
            -- * Main
            testSimpleMain, runTestSimple, qcTestSimpleMain,
            
            -- * Plan
            plan,

            -- * Test functions
            ok, isnt, is, like, unlike, isRight,

            -- * Diagnostics
            loc, diag, diagen) where

import Control.Monad.Trans.State.Plus
import Control.Monad.State
import System.Exit (exitFailure)
import Data.List (isInfixOf, intercalate)
import System.IO (hPutStrLn, stderr)
import qualified Language.Haskell.TH as TH
import Test.QuickCheck (Testable(property), quickCheckResult, Gen)
import Test.QuickCheck.Test (isSuccess)
import Test.QuickCheck.Property (Result(reason), succeeded, failed)

-- | Is used in 'like', 'unlike' tests.
class Likeable a b where
    -- | Returns 'True' if @a@ is like @b@
    isLike :: a -> b -> Bool

instance Eq a => Likeable [a] [a] where
    isLike = flip isInfixOf

data TSOutput = StdOut String | StdErr String
data TSState = TSS { tsCounter :: Int, tsFailed :: Int, tsPlanned :: Int, tsLoc :: Maybe TH.Loc
                            , tsOutput :: [TSOutput] }

-- | Test.Simple is implemented as monad transformer.
newtype TestSimpleT m a = MkTST { unTST :: StatePlusT TSState m a }
                                deriving (Functor, MonadTrans, Monad, MonadPlus
                                            , MonadState TSState, MonadIO)

emptyState :: TSState
emptyState = TSS 0 0 0 Nothing []

finishTS :: Monad m => TestSimpleT m a -> m (Bool, TSState)
finishTS m = do
    ms <- execStatePlusT (unTST m) emptyState
    finState <- execStatePlusT (unTST finish) ms
    return (not $ isFailed finState, finState)
    where
    finish = do
        s <- get
        let fld = tsFailed s > 0
        let mismatch = (tsPlanned s /= tsCounter s)
        if fld
                then diag $ "Looks like you failed " ++ show (tsFailed s)
                                ++ " test of " ++ show (tsPlanned s) ++ "."
                else if mismatch
                            then diag $ "Looks like you planned " ++ show (tsPlanned s)
                                                ++ " tests but ran " ++ show (tsCounter s) ++ "."
                            else return ()
        modify finOutput
        return $ not (fld || mismatch)
    finOutput s = s { tsOutput = (StdOut $ "1.." ++ show (tsPlanned s)):(reverse $ tsOutput s) }
    isFailed s = tsFailed s > 0 || (tsPlanned s /= tsCounter s)

-- | Runs 'TestSimpleT' transformer. Returns whether the tests where successful and resulting
-- output.
runTestSimple :: Monad m => TestSimpleT m a -> m (Bool, [String])
runTestSimple m = do
    (b, s) <- finishTS m
    return (b, map toStr $ tsOutput s)
    where toStr (StdOut str) = str
          toStr (StdErr str) = str

-- | Runs 'TestSimpleT' transformer in 'IO'. Outputs results in TAP format.
-- Exits with error on test failure.
--
testSimpleMain :: MonadIO m => TestSimpleT m a -> m ()
testSimpleMain m = do
    (b, s) <- finishTS m
    liftIO $ do
        mapM_ printLine $ tsOutput s
        unless b exitFailure
    where printLine (StdOut s) = putStrLn s
          printLine (StdErr s) = hPutStrLn stderr s

-- | Is @Bool@ ok?
ok :: Monad m => Bool -> TestSimpleT m Bool
ok b = do
    s <- get
    let oks = "ok " ++ show (tsCounter s + 1)
    put $ s { tsCounter = (tsCounter s) + 1
                , tsFailed = (tsFailed s) + if b then 0 else 1
                , tsOutput = (StdOut $ if b then oks else "not " ++ oks):(tsOutput s)
            }
    unless b $ diag $ concat [ "  Failed test", showLoc s ]
    return b

showLoc :: TSState -> String
showLoc s = " at " ++ go (tsLoc s) ++ "." where
    go Nothing = "unknown location"
    go (Just l) = concat [ TH.loc_filename l, " line ", show $ fst $ TH.loc_start l ]

(>>?) :: Monad m => m Bool -> m () -> m Bool
m >>? d = do
    b <- m
    unless b d
    return b

diagVals :: Monad m => String -> String -> String -> String-> TestSimpleT m ()
diagVals as a bs b = do
    diag $ concat [ spaces, as, " ", a ]
    diag $ concat [ bs, " ", b ]
    where spaces = take (length bs - length as) $ cycle " "

-- | Are values different?
isnt :: (Eq a, Show a, Monad m) => a -> a -> TestSimpleT m Bool
isnt a b = ok (a /= b) >>? diagVals "got:" (show a) "expected:" "anything else"

-- | Are values equal?
is :: (Eq a, Show a, Monad m) => a -> a -> TestSimpleT m Bool
is a b = ok (a == b) >>? diagVals "got:" (show a) "expected:" (show b)

-- | Is @a@ like @b@?
like :: (Show a, Show b, Likeable a b, Monad m) => a -> b -> TestSimpleT m Bool
like a b = ok (isLike a b) >>? diagVals "" (show a) "doesn't match" (show b)

-- | Is @a@ unlike @b@?
unlike :: (Show a, Show b, Likeable a b, Monad m) => a -> b -> TestSimpleT m Bool
unlike a b = ok (not $ isLike a b) >>? diagVals "" (show a) "matches" (show b)

-- | Is 'Either' right?
isRight :: (Monad m, Show a) => Either a b -> TestSimpleT m Bool
isRight (Right _) = ok True
isRight (Left a) = ok False >>? diagVals "got Left:" (show a) "expected:" "Right"

-- | Outputs diagnostics message.
diag :: Monad m => String -> TestSimpleT m ()
diag s = modify (\st -> st { tsOutput = (StdErr $ "# " ++ s):(tsOutput st) })

-- | Sets expected number of tests. Running more or less tests is considered failure.
-- Note, that plans are composable, e.g:
--
-- @
-- (plan 1 >> ok True) >> (plan 1 >> ok True)
-- @
--
-- will expect 2 tests.
plan :: Monad m => Int -> TestSimpleT m ()
plan i = modify (\st -> st { tsPlanned = tsPlanned st + i })

-- | Records current location to output in case of failures.
-- Necessary caveat: failing later without updating location produces the last location recorded.
loc :: TH.Q TH.Exp
loc = do
    l <- TH.location
    let ql = liftLoc l
    [| modify (\s -> s { tsLoc = Just $ql }) |]

liftLoc :: TH.Loc -> TH.Q TH.Exp
liftLoc l = [| TH.Loc f p m s e |] where
    f = TH.loc_filename l
    p = TH.loc_package l
    m = TH.loc_module l
    s = TH.loc_start l
    e = TH.loc_end l

instance Testable (TestSimpleT Gen a) where
    property m = do
        (b, lns) <- runTestSimple m
        property $ if b then succeeded else failed { reason = intercalate "\n" lns }

-- | Run some 'Testable' monad through 'QuickCheck'. Exit with failure on error.
qcTestSimpleMain :: (Testable (m a), Monad m) => m a -> IO ()
qcTestSimpleMain m = do
    res <- quickCheckResult m
    unless (isSuccess res) exitFailure

-- | Generates and logs (through 'diag') arbitrary value. Also outputs current location.
diagen :: Show a => String -> Gen a -> TestSimpleT Gen a
diagen msg gen = do
    a <- lift gen
    s <- get
    diag $ concat [ msg, ": ", show a, showLoc s ]
    return a