hedgehog-0.1: src/Hedgehog/Internal/Runner.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DoAndIfThenElse #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
module Hedgehog.Internal.Runner (
-- * Runner
RunnerConfig(..)
, check
, checkGroupWith
, recheck
-- * Internal
, checkReport
, checkConsoleRegion
, checkNamed
) where
import Control.Concurrent.Async (forConcurrently)
import Control.Concurrent.MVar (MVar)
import qualified Control.Concurrent.MVar as MVar
import qualified Control.Concurrent.QSem as QSem
import Control.Monad (when)
import Control.Monad.Catch (MonadMask(..), MonadCatch(..), catchAll, bracket)
import Control.Monad.IO.Class (MonadIO(..))
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Traversable (for)
import qualified GHC.Conc as Conc
import Hedgehog.Gen (runGen)
import qualified Hedgehog.Gen as Gen
import Hedgehog.Internal.Report
import Hedgehog.Internal.Seed (Seed)
import qualified Hedgehog.Internal.Seed as Seed
import Hedgehog.Internal.Tree (Tree(..), Node(..))
import Hedgehog.Internal.Property (PropertyName(..), GroupName(..))
import Hedgehog.Internal.Property (Test, Log(..), Failure(..), runTest)
import Hedgehog.Internal.Property (Property(..), PropertyConfig(..))
import Hedgehog.Internal.Property (ShrinkLimit, withTests)
import Hedgehog.Range (Size)
import Language.Haskell.TH.Lift (deriveLift)
import System.Console.Regions (ConsoleRegion, RegionLayout(..), LiftRegion)
import qualified System.Console.Regions as Console
import System.Environment (lookupEnv)
import Text.Read (readMaybe)
-- | Configuration for a property test run.
--
data RunnerConfig =
RunnerConfig {
-- | The number of property tests to run concurrently. 'Nothing' means
-- use one worker per processor.
runnerWorkers :: !(Maybe Int)
} deriving (Eq, Ord, Show)
findM :: Monad m => [a] -> b -> (a -> m (Maybe b)) -> m b
findM xs0 def p =
case xs0 of
[] ->
return def
x0 : xs ->
p x0 >>= \m ->
case m of
Nothing ->
findM xs def p
Just x ->
return x
isFailure :: Node m (Maybe (Either x a, b)) -> Bool
isFailure = \case
Node (Just (Left _, _)) _ ->
True
_ ->
False
takeSmallest ::
MonadIO m
=> Size
-> Seed
-> ShrinkCount
-> ShrinkLimit
-> (Status -> m ())
-> Node m (Maybe (Either Failure (), [Log]))
-> m Status
takeSmallest size seed shrinks slimit updateUI = \case
Node Nothing _ ->
pure GaveUp
Node (Just (x, w)) xs ->
case x of
Left (Failure loc err mdiff) -> do
let
failure =
mkFailure size seed shrinks loc err mdiff w
updateUI $ Shrinking failure
if shrinks >= fromIntegral slimit then
-- if we've hit the shrink limit, don't shrink any further
pure $ Failed failure
else
findM xs (Failed failure) $ \m -> do
o <- runTree m
if isFailure o then
Just <$> takeSmallest size seed (shrinks + 1) slimit updateUI o
else
return Nothing
Right () ->
return OK
checkReport ::
forall m.
MonadIO m
=> MonadCatch m
=> PropertyConfig
-> Size
-> Seed
-> Test m ()
-> (Report -> m ())
-> m Report
checkReport cfg size0 seed0 test0 updateUI =
let
test =
catchAll test0 (fail . show)
loop :: TestCount -> DiscardCount -> Size -> Seed -> m Report
loop !tests !discards !size !seed = do
updateUI $ Report tests discards Running
if size > 99 then
-- size has reached limit, reset to 0
loop tests discards 0 seed
else if tests >= fromIntegral (propertyTestLimit cfg) then
-- we've hit the test limit, test was successful
pure $ Report tests discards OK
else if discards >= fromIntegral (propertyDiscardLimit cfg) then
-- we've hit the discard limit, give up
pure $ Report tests discards GaveUp
else
case Seed.split seed of
(s0, s1) -> do
node@(Node x _) <-
runTree . Gen.runDiscardEffect $ runGen size s0 (runTest test)
case x of
Nothing ->
loop tests (discards + 1) (size + 1) s1
Just (Left _, _) ->
let
mkReport =
Report (tests + 1) discards
in
fmap mkReport $
takeSmallest
size
seed
0
(propertyShrinkLimit cfg)
(updateUI . mkReport)
node
Just (Right (), _) ->
loop (tests + 1) discards (size + 1) s1
in
loop 0 0 size0 seed0
checkConsoleRegion ::
MonadIO m
=> ConsoleRegion
-> Maybe PropertyName
-> Size
-> Seed
-> Property
-> m Report
checkConsoleRegion region name size seed prop =
liftIO $ do
report <-
checkReport (propertyConfig prop) size seed (propertyTest prop) $ \report -> do
setRegionReport region name report
setRegionReport region name report
pure report
checkNamed :: MonadIO m => ConsoleRegion -> Maybe PropertyName -> Property -> m Bool
checkNamed region name prop = do
seed <- liftIO Seed.random
report <- checkConsoleRegion region name 0 seed prop
pure $
reportStatus report == OK
-- | Check a property.
--
check :: MonadIO m => Property -> m Bool
check prop = do
liftIO . displayRegion $ \region ->
checkNamed region Nothing prop
-- | Check a property using a specific size and seed.
--
recheck :: MonadIO m => Size -> Seed -> Property -> m ()
recheck size seed prop0 = do
let prop = withTests 1 prop0
_ <- liftIO . displayRegion $ \region ->
checkConsoleRegion region Nothing size seed prop
pure ()
-- | Check a group of properties using the specified runner config.
--
checkGroupWith ::
MonadIO m
=> RunnerConfig
-> GroupName
-> [(PropertyName, Property)]
-> m Bool
checkGroupWith config group props0 =
liftIO $ do
n <- maybe getNumWorkers pure (runnerWorkers config)
-- ensure one spare capability for concurrent-output, it's likely that our
-- tests will saturate all the capabilities they're given.
updateNumCapabilities (n + 1)
putStrLn $ "━━━ " ++ unGroupName group ++ " ━━━"
Console.displayConsoleRegions $ do
mvar <- MVar.newMVar (-1, Map.empty)
props <-
fmap (zip [0..]) . for props0 $ \(name, p) -> do
region <- Console.openConsoleRegion Linear
setRegionReport region (Just name) $ Report 0 0 Waiting
pure (name, p, region)
qsem <- QSem.newQSem n
results <-
forConcurrently props $ \(ix, (name, p, region)) ->
bracket (QSem.waitQSem qsem) (const $ QSem.signalQSem qsem) $ \_ -> do
ok <- checkNamed region (Just name) p
finishIndexedRegion mvar ix region
pure ok
pure $
and results
------------------------------------------------------------------------
-- concurrent-output utils
displayRegion ::
MonadIO m
=> MonadMask m
=> LiftRegion m
=> (ConsoleRegion -> m a)
-> m a
displayRegion =
Console.displayConsoleRegions .
bracket (Console.openConsoleRegion Linear) finishRegion
setRegionReport ::
MonadIO m
=> LiftRegion m
=> ConsoleRegion
-> Maybe PropertyName
-> Report
-> m ()
setRegionReport region name report = do
content <- renderReport name report
Console.setConsoleRegion region content
finishRegion :: (Monad m, LiftRegion m) => ConsoleRegion -> m ()
finishRegion region = do
content <- Console.getConsoleRegion region
Console.finishConsoleRegion region content
flushRegions ::
MonadIO m
=> MVar (Int, Map Int ConsoleRegion)
-> m ()
flushRegions mvar =
liftIO $ do
again <-
MVar.modifyMVar mvar $ \original@(minIx, regions0) ->
case Map.minViewWithKey regions0 of
Nothing ->
pure (original, False)
Just ((ix, region), regions) ->
if ix == minIx + 1 then do
finishRegion region
pure ((ix, regions), True)
else
pure (original, False)
when again $
flushRegions mvar
finishIndexedRegion ::
MonadIO m
=> MVar (Int, Map Int ConsoleRegion)
-> Int
-> ConsoleRegion
-> m ()
finishIndexedRegion mvar ix region = do
liftIO . MVar.modifyMVar_ mvar $ \(minIx, regions) ->
pure (minIx, Map.insert ix region regions)
flushRegions mvar
-- | Update the number of capabilities but never set it lower than it already
-- is.
--
updateNumCapabilities :: Int -> IO ()
updateNumCapabilities n = do
ncaps <- Conc.getNumCapabilities
Conc.setNumCapabilities (max n ncaps)
getNumWorkers :: IO Int
getNumWorkers = do
menv <- (readMaybe =<<) <$> lookupEnv "HEDGEHOG_WORKERS"
case menv of
Nothing ->
Conc.getNumProcessors
Just env ->
pure env
------------------------------------------------------------------------
-- FIXME Replace with DeriveLift when we drop 7.10 support.
$(deriveLift ''RunnerConfig)