packages feed

registry-hedgehog-0.3.0.0: src/Test/Tasty/HedgehogTest.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE IncoherentInstances #-}
{-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -fno-warn-missing-signatures #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}

-- |
--
-- This redefines the HedgehogTest from tasty-hedgehog to display
-- color during the reporting
module Test.Tasty.HedgehogTest
  ( HedgehogTest (..),
    HedgehogTestLimit (..),
    HedgehogDiscardLimit (..),
    HedgehogShrinkLimit (..),
    HedgehogShrinkRetries (..),
    HedgehogReplay (..),
    HedgehogShowReplay (..),
    ModuleName (..),
    testProperty,
    groupByModuleName,
    getModuleName,
  )
where

import Data.MultiMap hiding (foldr, size)
import GHC.Stack
import Hedgehog hiding (test, (===))
import Hedgehog.Internal.Config (UseColor, detectColor)
import Hedgehog.Internal.Property
import Hedgehog.Internal.Report as Hedgehog
import Hedgehog.Internal.Runner as Hedgehog
import Hedgehog.Internal.Seed as Seed
import Protolude as P hiding (empty, toList, unwords, words)
import qualified Protolude as P
import Test.Tasty as Tasty
import Test.Tasty.Options as Tasty
import Test.Tasty.Providers as Tasty
import Test.Tasty.Runners as Tasty
  ( TestTree (..),
    foldSingle,
    foldTestTree,
    trivialFold,
  )
import Prelude (String, unwords, words)

-- | Hedgehog Property as a Tasty Test
data HedgehogTest = HedgehogTest Tasty.TestName Property
  deriving (Typeable)

-- | Create a 'Test' from a Hedgehog property
testProperty :: Tasty.TestName -> Property -> Tasty.TestTree
testProperty name prop = singleTest name (HedgehogTest name prop)

instance Tasty.IsTest HedgehogTest where
  testOptions =
    return
      [ Tasty.Option (Proxy :: Proxy HedgehogReplay),
        Tasty.Option (Proxy :: Proxy HedgehogShowReplay),
        Tasty.Option (Proxy :: Proxy HedgehogTestLimit),
        Tasty.Option (Proxy :: Proxy HedgehogDiscardLimit),
        Tasty.Option (Proxy :: Proxy HedgehogShrinkLimit),
        Tasty.Option (Proxy :: Proxy HedgehogShrinkRetries)
      ]

  run opts (HedgehogTest name (Property pConfig pTest)) yieldProgress = do
    useColor <- detectColor
    let HedgehogReplay replay = lookupOption opts
        HedgehogTestLimit mTests = lookupOption opts
        HedgehogDiscardLimit mDiscards = lookupOption opts
        HedgehogShrinkLimit mShrinks = lookupOption opts
        HedgehogShrinkRetries mRetries = lookupOption opts
        showReplay = lookupOption opts
        config =
          PropertyConfig
            (fromMaybe (propertyDiscardLimit pConfig) mDiscards)
            (fromMaybe (propertyShrinkLimit pConfig) mShrinks)
            (fromMaybe (propertyShrinkRetries pConfig) mRetries)
            (NoConfidenceTermination $ fromMaybe (propertyTestLimit pConfig) mTests)
    randSeed <- Seed.random
    -- if we just run one test we choose a high size (knowing that the max size is 99)
    -- if the test fails we can turn it to a prop and let the shrinking process find a
    -- smaller counter-example
    let minSize = if propertyTestLimit config == 1 then 50 else 0
    let size = P.maybe minSize fst replay
        seed = P.maybe randSeed snd replay
    report <- checkReport config size seed pTest (yieldProgress . reportToProgress config)
    let resultFn =
          if reportStatus report == OK
            then testPassed
            else testFailed
    out <- reportOutput showReplay useColor name report
    return $ resultFn out

reportToProgress ::
  PropertyConfig ->
  Report Hedgehog.Progress ->
  Tasty.Progress
reportToProgress config (Report testsDone _ _ status) =
  let TestLimit testLimit = propertyTestLimit config
      ShrinkLimit shrinkLimit = propertyShrinkLimit config
      ratio x y = 1.0 * fromIntegral x / fromIntegral y
   in -- TODO add details for tests run / discarded / shrunk
      case status of
        Running ->
          Tasty.Progress "Running" (ratio testsDone testLimit)
        Shrinking fr ->
          Tasty.Progress "Shrinking" (ratio (failureShrinks fr) shrinkLimit)

reportOutput ::
  HedgehogShowReplay ->
  UseColor ->
  String ->
  Report Hedgehog.Result ->
  IO String
reportOutput (HedgehogShowReplay showReplay) useColor name report = do
  s <- renderResult useColor (Just (PropertyName name)) report
  pure $ case reportStatus report of
    Failed fr ->
      let size = failureSize fr
          seed = failureSeed fr
          replayStr =
            if showReplay
              then
                "  --hedgehog-replay \""
                  ++ show size
                  ++ " "
                  ++ show seed
                  ++ "\""
              else ""
       in s ++ replayStr ++ "\n"
    GaveUp ->
      s
    OK ->
      -- do not report hedgehog successes because they are redundant with the Tasty report
      -- except if there is coverage information
      if not . P.null . P.toList . coverageLabels . reportCoverage $ report
        then s
        else ""

propertyTestLimit :: PropertyConfig -> TestLimit
propertyTestLimit =
  let getTestLimit (EarlyTermination _ tests) = tests
      getTestLimit (NoEarlyTermination _ tests) = tests
      getTestLimit (NoConfidenceTermination tests) = tests
   in getTestLimit . propertyTerminationCriteria

-- * OPTIONS DEFINITIONS

-- | The replay token to use for replaying a previous test run
newtype HedgehogReplay = HedgehogReplay (Maybe (Size, Seed))
  deriving (Typeable)

instance IsOption HedgehogReplay where
  defaultValue = HedgehogReplay Nothing

  parseValue v = HedgehogReplay . Just <$> replay
    where
      -- Reads a replay token in the form "{size} {seed}"
      replay = (,) <$> safeRead (unwords size) <*> safeRead (unwords seed)
      (size, seed) = splitAt 2 $ words v

  optionName = return "hedgehog-replay"

  optionHelp = return "Replay token to use for replaying a previous test run"

-- | If a test case fails, show a replay token for replaying tests
newtype HedgehogShowReplay = HedgehogShowReplay Bool
  deriving (Typeable)

instance IsOption HedgehogShowReplay where
  defaultValue = HedgehogShowReplay True

  parseValue = fmap HedgehogShowReplay . safeRead

  optionName = return "hedgehog-show-replay"

  optionHelp = return "Show a replay token for replaying tests"

-- | The number of successful test cases required before Hedgehog will pass a test
newtype HedgehogTestLimit = HedgehogTestLimit (Maybe TestLimit)
  deriving (Eq, Ord, Show, Typeable)

instance IsOption HedgehogTestLimit where
  defaultValue = HedgehogTestLimit Nothing

  parseValue = fmap (HedgehogTestLimit . Just . TestLimit) . safeRead

  optionName = return "hedgehog-tests"

  optionHelp = return "Number of successful test cases required before Hedgehog will pass a test"

-- | The number of discarded cases allowed before Hedgehog will fail a test
newtype HedgehogDiscardLimit = HedgehogDiscardLimit (Maybe DiscardLimit)
  deriving (Eq, Ord, Show, Typeable)

instance IsOption HedgehogDiscardLimit where
  defaultValue = HedgehogDiscardLimit Nothing

  parseValue = fmap (HedgehogDiscardLimit . Just . DiscardLimit) . safeRead

  optionName = return "hedgehog-discards"

  optionHelp = return "Number of discarded cases allowed before Hedgehog will fail a test"

-- | The number of shrinks allowed before Hedgehog will fail a test
newtype HedgehogShrinkLimit = HedgehogShrinkLimit (Maybe ShrinkLimit)
  deriving (Eq, Ord, Show, Typeable)

instance IsOption HedgehogShrinkLimit where
  defaultValue = HedgehogShrinkLimit Nothing

  parseValue = fmap (HedgehogShrinkLimit . Just . ShrinkLimit) . safeRead

  optionName = return "hedgehog-shrinks"

  optionHelp = return "Number of shrinks allowed before Hedgehog will fail a test"

-- | The number of times to re-run a test during shrinking
newtype HedgehogShrinkRetries = HedgehogShrinkRetries (Maybe ShrinkRetries)
  deriving (Eq, Ord, Show, Typeable)

instance IsOption HedgehogShrinkRetries where
  defaultValue = HedgehogShrinkRetries Nothing

  parseValue = fmap (HedgehogShrinkRetries . Just . ShrinkRetries) . safeRead

  optionName = return "hedgehog-retries"

  optionHelp = return "Number of times to re-run a test during shrinking"

-- * GROUPING

-- | This allows the discovery of Hedgehog properties and their grouping by module name
--   in the test report.
--   Extract the ModuleName option value for a given test and
--   group all the tests with that option into the same test group
groupByModuleName :: TestTree -> TestTree
groupByModuleName testTree =
  let grouped =
        assocs $
          foldTestTree
            ( trivialFold
                { foldSingle = \os n t ->
                    let (ModuleName aModuleName) = lookupOption os :: ModuleName
                     in insert (toS aModuleName) (setOptionSet os $ singleTest n t) empty
                }
            )
            mempty
            testTree
   in TestGroup "All" (uncurry TestGroup <$> grouped)

-- | Option describing the current module name
newtype ModuleName = ModuleName Text deriving (Eq, Show)

-- | This option is not used on the command line, it is just used to annotate test groups
instance IsOption ModuleName where
  defaultValue = ModuleName "root"
  parseValue = fmap ModuleName . safeRead
  optionName = pure "module-name"
  optionHelp = pure "internal option used to group tests into the same module"
  optionCLParser = mkFlagCLParser mempty (ModuleName "root")

instance (Ord k) => Semigroup (MultiMap k v) where
  (<>) m1 m2 = fromList (toList m1 <> toList m2)

instance (Ord k) => Monoid (MultiMap k v) where
  mempty = empty
  mappend = (<>)

-- | This is unfortunate. Due to the API for `foldTestTree` in Tasty
--   giving back the current `OptionSet` applicable to a single test
--   it is not possible to re-set those option values on that test
--   without listing them exhaustively. This means
--   that if other options are set on tests in that file, they need to be
--   added in that function
setOptionSet :: OptionSet -> TestTree -> TestTree
setOptionSet os =
  localOption (lookupOption os :: HedgehogTestLimit)
    . localOption (lookupOption os :: HedgehogShrinkLimit)
    . localOption (lookupOption os :: HedgehogReplay)

-- | Return the module name of the current callstack
getModuleName :: HasCallStack => Prelude.String
getModuleName =
  case getCallStack callStack of
    ((_, loc) : _) -> srcLocModule loc
    _ -> "root"