packages feed

ideas-0.7: src/Common/TestSuite.hs

-----------------------------------------------------------------------------
-- Copyright 2010, Open Universiteit Nederland. This file is distributed 
-- under the terms of the GNU General Public License. For more information, 
-- see the file "LICENSE.txt", which is included in the distribution.
-----------------------------------------------------------------------------
-- |
-- Maintainer  :  bastiaan.heeren@ou.nl
-- Stability   :  provisional
-- Portability :  portable (depends on ghc)
--
-- A lightweight wrapper around the QuickCheck library. It introduces the
-- notion of a test suite, and it stores the test results for later inspection
-- (e.g., for the generation of a test report). A test suite has a monadic
-- interface.
--
-----------------------------------------------------------------------------
module Common.TestSuite 
   ( -- * Test Suite Monad
     TestSuite, MonadIO(..)
     -- * Test suite constructors
   , suite, addProperty, addPropertyWith
   , assertTrue, assertTrueMsg, assertNull, assertEquals
   , assertIO, warn
     -- * Running a test suite
   , runTestSuite, runTestSuiteResult
     -- * Test Suite Result
   , TestSuiteResult, subResults
   , makeSummary, printSummary
   , makeTestLog, makeTestLogWith, printTestLog
     -- * Formatting
   , FormatLog(..), formatLog, formatTimeDiff
   ) where

import Control.Arrow
import Data.List
import Data.Monoid
import qualified Data.Sequence as S
import Data.Foldable (toList)
import Test.QuickCheck
import Control.Monad.State
import System.Time hiding (formatTimeDiff)

----------------------------------------------------------------
-- Test Suite Monad

-- Integer corresponds to the number of characters on the current line, and
-- is used for formatting
newtype TestSuiteM a = TSM { unTSM :: M a }

type M a = StateT (Int, ResultTree) IO a
type TestSuite = TestSuiteM ()

instance Monad TestSuiteM where
   return  = TSM . return
   m >>= f = TSM (unTSM m >>= unTSM . f)
   fail s  = do assertTrueMsg "" s False
                return (error "TestSuite.fail: do not bind result")

instance MonadIO TestSuiteM where
   liftIO =  TSM . liftIO

instance Monoid a => Monoid (TestSuiteM a) where
   mempty  = return mempty
   mappend = (>>)

----------------------------------------------------------------
-- Test suite constructors

-- | Construct a (named) test suite containing tests and other suites
suite :: String -> TestSuite -> TestSuite
suite s m = TSM $ do
   newline
   liftIO $ putStrLn s
   reset
   (t, td) <- getDiff (withEmptyTree (unTSM m))
   addTree (labeled (s, td) t)

-- | Add a QuickCheck property to the test suite. The first argument is 
-- a label for the property
addProperty :: Testable prop => String -> prop -> TestSuite
addProperty = flip addPropertyWith stdArgs

-- | Add a QuickCheck property to the test suite, also providing a test
-- configuration (Args)
addPropertyWith :: Testable prop => String -> Args -> prop -> TestSuite
addPropertyWith s args p = TSM $ do
   newlineIndent
   r <- liftIO $ quickCheckWithResult args p
   reset
   addResult s (toTestResult (maxSuccess args) r)

assertTrue :: String -> Bool -> TestSuite
assertTrue msg = assertIO msg . return

assertTrueMsg :: String -> String -> Bool -> TestSuite
assertTrueMsg s msg = addAssertion (Error msg) s . return

assertNull :: Show a => String -> [a] -> TestSuite
assertNull s xs = addAssertion (f xs) s (return (null xs))
 where f = Error . concat . intersperse "\n" . map show
 
assertEquals :: (Eq a, Show a) => String -> a -> a -> TestSuite
assertEquals s x y = addAssertion msg s (return (x==y))
 where msg = Error ("Not equal: " ++ show x ++ " and " ++ show y)

assertIO :: String -> IO Bool -> TestSuite
assertIO = addAssertion (Error "Assertion failed")

warn :: String -> TestSuite
warn msg = addAssertion (Warning msg) "" (return False)

-- local helpers
addAssertion :: TestResult -> String -> IO Bool -> TestSuite
addAssertion msg s io = TSM $ do
   b <- liftIO (io `catch` \_ -> return False)
   if b then do 
      dot
      addResult s (Ok 1)
    else do
      newlineIndent
      liftIO $ putStrLn (s ++ ": " ++ show msg)
      reset
      addResult s msg

addResult :: String -> TestResult -> M ()
addResult s r = addTree (single (s, r))

addTree :: ResultTree -> M ()
addTree t = modify (second (`mappend` t))

withEmptyTree :: M () -> M ResultTree
withEmptyTree m = do
   t0 <- gets snd
   modify (second (const mempty))
   m
   tr <- gets snd
   modify (second (const t0))
   return tr

-- formatting helpers
newline :: M ()
newline = do
   i <- gets fst
   when (i>0) (liftIO $ putChar '\n')
   reset

newlineIndent :: M ()
newlineIndent = do
   newline
   liftIO $ putStr "   "
   modify (first (const 3))

dot :: M ()
dot = do
   i <- gets fst
   unless (i>0 && i<60) newlineIndent
   liftIO $ putChar '.'
   modify (first (+1))

reset :: M ()
reset = modify (first (const 0))

----------------------------------------------------------------
-- Running a test suite

runTestSuite :: TestSuite -> IO ()
runTestSuite s = runTestSuiteResult s >> return ()

runTestSuiteResult :: TestSuite -> IO TestSuiteResult
runTestSuiteResult s = liftM TSR $ getDiff $ liftM snd $
   execStateT (unTSM s >> newline) (0, mempty)

----------------------------------------------------------------
-- Test Suite Result

newtype TestSuiteResult = TSR (ResultTree, TimeDiff)

type ResultTree = Tree (String, TimeDiff) (String, TestResult)

data TestResult = Ok !Int | Error String | Warning String

instance Show TestResult where
   show (Ok _)        = "Ok"
   show (Error msg)   = "Error: "   ++ msg
   show (Warning msg) = "Warning: " ++ msg

-- one-line summary
instance Show TestSuiteResult where
   show (TSR (tree, diff)) = 
      let (n, nf, nw) = collectInfo tree
      in "(tests: " ++ show n ++ ", failures: " ++ show nf ++
         ", warnings: " ++ show nw ++ ", " ++ formatTimeDiff diff ++ ")"

subResults :: TestSuiteResult -> [(String, TestSuiteResult)]
subResults (TSR (tree, _)) = 
   let f ((s, diff), t) = (s, TSR (t, diff))
   in map f (subtrees tree)

printSummary :: TestSuiteResult -> IO ()
printSummary = putStrLn . makeSummary

makeSummary :: TestSuiteResult -> String
makeSummary result@(TSR (tree, diff)) = unlines $
   [ line
   , "Tests    : " ++ show n
   , "Failures : " ++ show nf
   , "Warnings : " ++ show nw
   , "\nTime     : " ++ formatTimeDiff diff
   , "\nSuites: "
   ] ++ map f (subResults result) 
     ++ [line]
 where
   line        = replicate 75 '-'
   (n, nf, nw) = collectInfo tree
   f (name, r) = "   " ++ name ++ "   " ++ show r

printTestLog :: TestSuiteResult -> IO ()
printTestLog = putStrLn . makeTestLog

makeTestLog :: TestSuiteResult -> String
makeTestLog = unlines . makeTestLogWith formatLog

makeTestLogWith :: Monoid a => FormatLog a -> TestSuiteResult -> a
makeTestLogWith fm (TSR (tree, diff)) = formatRoot fm diff (make [] tree)
 where
   make loc = mconcat . map (either forTests forSuite) . treeToList
    where
      treeToList = 
         let op (i, ys) y = 
                case y of 
                   Left b  -> (i, Left b:ys)
                   Right p -> (i+1, Right (loc ++ [i], p):ys)
         in reverse . snd . foldl op (1, []) . collectLevel

      forSuite (nl, ((s, d), t)) = 
         formatSuite fm nl s (collectInfo t) d (make nl t)
      
      forTests [] = mempty
      forTests list@((s, result) : rest) = 
         case result of            
            Warning msg -> next (formatWarning fm s msg)
            Error msg   -> next (formatFailure fm s msg)
            Ok _        ->
               let (ys, zs) = span (isOk . snd) list
                   sucs     = [ (x, n) | (x, Ok n) <- ys ]
               in formatSuccesses fm sucs `mappend` forTests zs
       where
         next a = a `mappend` forTests rest

data FormatLog a = FormatLog
   { formatRoot      :: TimeDiff -> a -> a
   , formatSuite     :: [Int] -> String -> (Int, Int, Int) -> TimeDiff -> a -> a
   , formatSuccesses :: [(String, Int)] -> a
   , formatFailure   :: String -> String -> a
   , formatWarning   :: String -> String -> a
   }

formatLog :: FormatLog [String]
formatLog = FormatLog
   { formatRoot = \td a -> 
        a ++ ["\n(Total time: " ++ formatTimeDiff td ++ ")"]
   , formatSuite = \loc s _ td a -> 
        [showLoc loc ++ ". " ++ s] ++ a ++ 
        ["  (" ++ formatTimeDiff td ++ " for " ++ s ++ ")"]
   , formatSuccesses = \xs -> 
        let f (_, n) = if n==1 then "." else "(" ++ show n ++ " tests)"
        in ["   " ++ concatMap f xs]
   , formatFailure = \s msg ->
        ["   " ++ putLabel s ++ "Error: " ++ msg]
   , formatWarning = \s msg ->
        ["   " ++ putLabel s ++ "Warning: " ++ msg]
   }
 where 
   putLabel s = if null s then "" else s ++ ": "

formatTimeDiff :: TimeDiff -> String
formatTimeDiff d@(TimeDiff z1 z2 z3 h m s p)
   | any (/=0) [z1,z2,z3] = timeDiffToString d
   | s >= 60      = formatTimeDiff (timeDiff ((h*60+m)*60+s) p)
   | h==0 && m==0 = show inSec ++ " secs"
   | otherwise    = show (60*h+m) ++ ":" ++ digSec ++ " mins" 
 where
   milSec = 1000*toInteger s + p `div` 1000000000
   inSec  = fromIntegral milSec / 1000 :: Double
   digSec = (if s < 10 then ('0' :) else id) (show s)
   timeDiff n pc = 
      let (rest, sn) = n `divMod` 60
          (hr, mr)   = rest `divMod` 60
      in TimeDiff 0 0 0 hr mr sn pc

-----------------------------------------------------
-- Utility functions

-- A sequence of leafs (Left) or labeled items (Right)
newtype Tree a b = T { unT :: S.Seq (Either b (a, Tree a b)) }

instance Monoid (Tree a b) where
   mempty = T mempty
   mappend (T a) (T b) = T (mappend a b)
  
single :: b -> Tree a b
single = T . S.singleton . Left

labeled :: a -> Tree a b -> Tree a b
labeled a t = T (S.singleton (Right (a, t)))
  
toTestResult :: Int -> Result -> TestResult
toTestResult n result = 
   case result of
      Success _           -> Ok n
      Failure _ _ msg _   -> Error msg
      NoExpectedFailure _ -> Error "no expected failure"
      GaveUp i _          -> Warning ("passed only " ++ show i ++ " tests")
            
showLoc :: [Int] -> String
showLoc = concat . intersperse "." . map show

collectInfo :: Tree a (String, TestResult) -> (Int, Int, Int)
collectInfo tree = (length tests, length failures, length warnings)
 where
   tests    = flatten tree
   failures = [ msg | (_, Error msg)   <- tests ]
   warnings = [ msg | (_, Warning msg) <- tests ]

isOk :: TestResult -> Bool
isOk (Ok _) = True
isOk _      = False

subtrees :: Tree a b -> [(a, Tree a b)]
subtrees t = [ p | Right p <- collectLevel t ]

flatten :: Tree a b -> [b]
flatten t = [ b | x <- collectLevel t, b <- either id (flatten . snd) x ]

collectLevel :: Tree a b -> [Either [b] (a, Tree a b)]
collectLevel = combine [] . toList . unT
 where
   combine acc []             = f acc
   combine acc (Left a:rest)  = combine (a:acc) rest
   combine acc (Right b:rest) = f acc ++ (Right b : combine [] rest)
   
   f acc = [ Left (reverse acc) | not (null acc) ] 

getDiff :: MonadIO m => m a -> m (a, TimeDiff)
getDiff action = do
   t0 <- liftIO getClockTime
   a  <- action
   t1 <- liftIO getClockTime
   return (a, diffClockTimes t1 t0)

-- Example
{-
main :: IO ()
main = do
   r <- runTestSuiteResult $ do
      suite "A" $ do
         addProperty "p1" p1
         addProperty "p1" p1
         suite "A1" $ addProperty "p2" p2
         suite "A2" $ return ()
         addProperty "p3" p3
      suite "B" $ do
         addProperty "p4" p4
         addProperty "W" (\xs -> length (xs::[Int]) > 100 ==> True)
      suite "C" $ do
         addProperty "p5" p5
         assertTrue "sorted" (sort [3,2,1] == [1,2,3])
         fail "This is a failure"
         warn "This is a warning"
         assertEquals "eq" (sort [1,2,2]) (nub [1,2,2]) 
         assertTrue "yes" True
         
   printSummary r
   printTestLog r
   --print r
   --print (subResults r)
 where      
   p1 xs = sort (xs::[Int]) == sort (sort xs)
   p2 xs = reverse (reverse xs) == (xs::[Int])
   p3 xs = head (sort xs) == minimum (xs::[Int])
   p4 xs = sort (nub xs) == nub (sort (xs::[Int]))
   p5 xs = reverse (sort xs) == sort (reverse (xs :: [Int]))

main = runTestSuite $ suite "A" $ assertIO "B" (return True) >> 
   assertIO "D" (fail "boe") >> assertIO "C" (return True) -}