packages feed

doc-review-0.7: test/Main.hs

{-# LANGUAGE OverloadedStrings #-}
module Main
    ( main )
where

import State.Types ( State, ChapterId, mkChapterId, CommentId
                   , Comment, getCounts, findComments, mkCommentId
                   , addChapter, Comment(..), addComment, SessionId(..)
                   , getLastInfo, SessionInfo(..), getChapterComments
                   , getChapterURI
                   )
import qualified State.Mem
import qualified State.Disk
import qualified State.SQLite

import Network.URI ( URI, parseRelativeReference )
import Data.Function ( on )
import Data.List ( sort, sortBy, intercalate )
import Data.Maybe ( fromJust )
import Data.Array.Unboxed ( UArray, listArray, bounds, (!) )
import Data.Char ( isPrint, chr )
import System.Directory ( createDirectory, getTemporaryDirectory, removeDirectoryRecursive )
import System.FilePath ( (</>) )
import Control.Monad ( replicateM, forM_, unless, join, replicateM_, foldM )
import Control.Monad.Random ( getRandomR, getRandom )
import Control.Applicative ( (<$>), (<*>) )
import Control.Exception ( finally )
import System.IO ( stdout, hFlush )
import qualified Data.Text as T
import qualified Data.ByteString as B
import qualified Data.Text.Encoding as E

withTemporaryDirectory :: (FilePath -> IO a) -> IO a
withTemporaryDirectory act = do
  t <- getTemporaryDirectory
  let choices = ['0'..'9'] ++ ['a'..'z'] ++ ['A'..'Z']
      rndChar = (choices !!) <$> getRandomR (0, length choices - 1)
  randPart <- replicateM 8 rndChar
  let d = t </> ("test-" ++ randPart)
  createDirectory d
  act d `finally` removeDirectoryRecursive d

data StoreType = Disk | SQLite | Mem deriving (Show, Eq, Enum, Bounded)

mkStore :: FilePath -> StoreType -> IO State
mkStore d t = case t of
                Mem    -> State.Mem.new
                Disk   -> State.Disk.new (d </> "flat-file")
                SQLite -> State.SQLite.new (d </> "state.db")

withRandomStores :: ((StoreType, State) -> (StoreType, State) -> IO a) -> IO a
withRandomStores act = do
  elim <- getRandomR (0, 2)
  let (before, (_:after)) = splitAt elim [Disk, SQLite, Mem]
      ts = before ++ after
  withTemporaryDirectory $ \d ->
      do [st1, st2] <- zip ts <$> mapM (mkStore d) ts
         act st1 st2

data Operation = GetCounts (Maybe ChapterId)
               | FindComments CommentId
               | AddChapter ChapterId [CommentId] (Maybe URI)
               | AddComment CommentId (Maybe ChapterId) Comment
               | GetLastInfo SessionId
               | GetChapterComments ChapterId
               | GetChapterURI ChapterId
                 deriving Show

data Result = Unit
            | LastInfo (Maybe SessionInfo)
            | Counts [(CommentId, Int)]
            | Comments [Comment]
            | ChapterComments [(CommentId, Comment)]
            | ChapterURI (Maybe URI)
              deriving (Eq, Show)

applyOp :: Operation -> State -> IO Result
applyOp (GetCounts p) st               = Counts . sort <$> getCounts st p
applyOp (FindComments cId) st          = Comments <$> findComments st cId
applyOp (AddChapter chId cIds mURI) st = addChapter st chId cIds mURI >> return Unit
applyOp (AddComment cId mChId c) st    = addComment st cId mChId c >> return Unit
applyOp (GetLastInfo sId) st           = LastInfo <$> getLastInfo st sId
applyOp (GetChapterComments chId) st   = ChapterComments <$> getChapterComments st chId
applyOp (GetChapterURI chId) st        = ChapterURI <$> getChapterURI st chId

choice :: [a] -> IO a
choice xs = (xs !!) <$> getRandomR (0, length xs - 1)

shuffle :: [a] -> IO [a]
shuffle xs = do
  is <- replicateM (length xs) getRandom :: IO [Double]
  return $ map snd $ sortBy (compare `on` fst) $ zip is xs

randomOp :: IO Operation
randomOp = join $ choice $ readOps ++ writeOps

randomWriteOp :: IO Operation
randomWriteOp = join $ choice writeOps

readOps :: [IO Operation]
readOps = [ GetCounts <$> maybeRand randomChapterId
          , FindComments <$> randomCommentId
          , GetLastInfo <$> randomSessionId
          , GetChapterComments <$> randomChapterId
          , GetChapterURI <$> randomChapterId
          ]

writeOps :: [IO Operation]
writeOps = [ let cIds = do
                   n <- getRandomR (0, 100)
                   replicateM n $ randomCommentId
             in AddChapter <$>
                randomChapterId <*>
                cIds <*>
                maybeRand randomRelativeReference
           , AddComment
             <$> randomCommentId
             <*> maybeRand randomChapterId
             <*> randomComment
           ]

maybeRand :: IO a -> IO (Maybe a)
maybeRand x = do
  doGen <- getRandom
  if doGen then Just <$> x else return Nothing

randomChapterId :: IO ChapterId
randomChapterId = fromJust . mkChapterId <$> randomText

randomCommentId :: IO CommentId
randomCommentId = fromJust . mkCommentId <$> randomText

randomSessionId :: IO SessionId
randomSessionId = SessionId <$> randomBS

randomRelativeReference :: IO URI
randomRelativeReference = do
  nSegs <- getRandomR (0, 5)
  let getSeg = join $ choice
               [ choice ["a", "b", "c"]
               , do segLength <- getRandomR (1, 10)
                    replicateM segLength $
                               choice $ ['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9']
               ]
  fromJust . parseRelativeReference . ('/':) . intercalate "/" <$> replicateM nSegs getSeg

usableChars :: UArray Int Char
usableChars = listArray (0, length cs - 1) cs
    where
      cs = filter isPrint [chr 0..chr 127]

randomBS :: IO B.ByteString
randomBS = E.encodeUtf8 <$> randomText

randomText :: IO T.Text
randomText = join $ choice [commonValue, completelyRandom]
    where
      completelyRandom = do
        let randomChar = do
              i <- getRandomR $ bounds usableChars
              return $ usableChars ! i
        n <- getRandomR (2, 40)
        T.pack <$> replicateM n randomChar
      commonValue = choice ["alpha", "beta", "gamma", "delta"]

randomOps :: Int -> Int -> IO [Operation]
randomOps minOps maxOps = do
  n <- getRandomR (minOps, maxOps)
  replicateM n randomOp

doRandomOperations :: (StoreType, State) -> (StoreType, State) -> IO ()
doRandomOperations (ty1, st1) (ty2, st2) = do
  ops <- randomOps 10 500
  _ <- foldM (\soFar op -> do
                r1 <- applyOp op st1
                r2 <- applyOp op st2
                unless (r1 == r2) $ do
                  putStrLn $ "Got different results for " ++ show op ++ " on " ++
                           show ty1 ++ " and " ++ show ty2
                  forM_ (soFar [op]) print
                  putStrLn $ "Results were:"
                  print ty1
                  print r1
                  putStrLn $ replicate 50 '-'
                  print ty2
                  print r2
                  putStrLn $ replicate 50 '-'
                  error "Test failed"
                return (soFar . (op:))) id ops
  return ()

randomComment :: IO Comment
randomComment = Comment
                <$> randomText
                <*> randomText
                <*> maybeRand randomText
                <*> randomTime
                <*> randomSessionId
    where
      -- This one's a little sketchy, because we expect that the time
      -- of each comment will be monotonically non-decreasing. The
      -- stores should act sanely in the face of arbitrary time
      -- values, however, so I'll leave it as a completely random
      -- value.
      randomTime = realToFrac <$> (getRandomR (0, 2**32) :: IO Double)

-- |Test that adding a comment to a node makes it show up at the end
-- of the list of comments when it is subsequently loaded
storeLoadComment :: State -> IO ()
storeLoadComment st = do
  cId <- randomCommentId
  cs <- findComments st cId
  c <- randomComment
  addComment st cId Nothing c
  cs' <- findComments st cId
  unless (cs' == (cs ++ [c])) $
         error "Expected the added comment to come up after adding"

-- |Test that a comment for a node that is part of a chapter is
-- returned when we request comments for this chapter
storeLoadCommentChapter :: State -> IO ()
storeLoadCommentChapter st = do
  chId <- randomChapterId
  cId <- randomCommentId
  ref <- maybeRand randomRelativeReference
  addChapter st chId [cId] ref
  cs <- getChapterComments st chId
  c <- randomComment
  addComment st cId (Just chId) c
  let e = (cId, c)
  cs' <- getChapterComments st chId
  unless (length cs' == length cs + 1) $
         error $ unlines [ "Adding a comment to a chapter didn't add:"
                         , show cs
                         , show cs'
                         ]
  unless (e `elem` cs') $
         error "The expected pair is not part of the comments"

chapterCommentsOrderedByDate :: State -> IO ()
chapterCommentsOrderedByDate st = do
  cs <- getChapterComments st =<< randomChapterId
  let dates = map (cDate . snd) cs
      ordered = and $ zipWith (>=) dates (drop 1 dates)
  unless ordered $
         error "The comments are not ordered by date"

-- |Test some property (possibly with a side-effect)
runArbitraryTest :: State -> IO ()
runArbitraryTest st = ($ st) =<< choice allProps

-- |All defined properties
allProps :: [State -> IO ()]
allProps = [ storeLoadComment
           , chapterCommentsOrderedByDate
           , storeLoadCommentChapter
           ]

-- |Run all of the properties at least once, in an arbitrary order
runRandomTests :: State -> IO ()
runRandomTests st = do
  m <- getRandomR (0, 100)
  let randomProps = replicate m $ runArbitraryTest st
  props <- shuffle $ map ($ st) allProps ++ randomProps
  sequence_ props

main :: IO ()
main = do
  putStr "Testing store properties: "
  hFlush stdout

  -- Test properties of stores
  withTemporaryDirectory $ \d ->
      forM_ [minBound .. maxBound] $ \typ -> do
         putStr $ show typ ++ " "
         hFlush stdout

         st <- mkStore d typ

         -- Run some tests on an empty store
         runRandomTests st

         replicateM 10 $ do
           putStr "."
           hFlush stdout

           -- Do some random operations to get the store into a
           -- different state
           n <- getRandomR (10, 500)
           ops <- replicateM n randomWriteOp
           forM_ ops $ \op -> applyOp op st

           runRandomTests st

  putStrLn "done.\nRunning store consistency tests"
  -- Test that completely randomized operations have the same
  -- observable state between different store types
  replicateM_ 100 $ do
         withRandomStores doRandomOperations
         putStr "."
         hFlush stdout

  putStrLn "\ndone. Tests passed."