packages feed

cleveland-0.3.0: src/Test/Cleveland/Util.hs

-- SPDX-FileCopyrightText: 2021 Oxhead Alpha
-- SPDX-License-Identifier: LicenseRef-MIT-OA

{-# LANGUAGE QuantifiedConstraints #-}

-- | Testing utility functions used by testing framework itself or
-- intended to be used by test writers.

module Test.Cleveland.Util
  ( leftToShowPanic
  , (?-)

  -- * Property
  , failedTest
  , succeededTest
  , eitherIsLeft
  , eitherIsRight
  , meanTimeUpperBoundProp
  , meanTimeUpperBoundPropNF

  -- * Generator
  , genTuple2
  , genRandom
  , runGen

  -- * Roundtrip
  , roundtripTree
  , assertGoesBefore
  , goesBefore

  -- * Pretty-printing
  , formatValue
  , formatSomeValue
  , ShowWith(..)
  , Showing(..)

  -- * Time
  , ceilingUnit
  , timeToFixed
  , timeToNominalDiffTime

  -- * Bytes
  , stripOptional0x
  , fromHex
  , parseAddressFromHex

  -- * Traversals
  , mapEach
  , forEach

  -- * Re-exports
  --
  -- | These functions from @Time@ are re-exported here to make it convenient to call
  -- 'meanTimeUpperBoundProp' and 'meanTimeUpperBoundPropNF'.
  , mcs, ms, sec, minute
  ) where

import Debug qualified (show)

import Control.Lens qualified as L
import Control.Monad.Random (MonadRandom, evalRand, mkStdGen)
import Criterion (Benchmarkable, benchmarkWith', nf, whnf)
import Criterion.Main (defaultConfig)
import Criterion.Types (SampleAnalysis(anMean), Verbosity(Quiet), reportAnalysis, verbosity)
import Data.Fixed (Fixed, HasResolution)
import Data.Ratio ((%))
import Data.Singletons (demote)
import Data.Text qualified as T
import Data.Time (NominalDiffTime, secondsToNominalDiffTime)
import Data.Typeable (typeRep)
import Fmt (Buildable, Builder, build, pretty, (+|), (|+))
import Hedgehog
  (Gen, MonadGen, MonadTest, Property, annotate, evalIO, failure, forAll, property, success,
  tripping, withTests)
import Hedgehog.Gen qualified as Gen
import Hedgehog.Internal.Gen (runGenT)
import Hedgehog.Internal.Seed qualified as Seed
import Hedgehog.Internal.Tree (TreeT(runTreeT), nodeValue)
import Hedgehog.Range qualified as Range
import Statistics.Types (Estimate(estPoint))
import Test.HUnit (Assertion, assertFailure)
import Test.Tasty (TestTree)
import Test.Tasty.HUnit (testCase)
import Test.Tasty.Hedgehog (testProperty)
import Text.Hex (decodeHex)
import Text.Printf (printf)
import Text.Show qualified
import Time
  (KnownDivRat, KnownUnitName, Microsecond, Millisecond, Minute, Nanosecond, Picosecond, Rat,
  RatioNat, Second, Time, mcs, minute, ms, ns, sec, time, timeout, toUnit, unTime, unitNameVal)

import Morley.Michelson.Doc (DocItem, docItemPosition)
import Morley.Michelson.Typed (SingI, SomeConstrainedValue)
import Morley.Michelson.Typed qualified as T
import Morley.Tezos.Address
import Morley.Util.Constrained

import Test.Cleveland.Instances ()

{-
Note [Hedgehog & withFrozenCallStack]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When an Hedgehog test fails, Hedgehog displays the source code for all callstack frames,
and annotates the top-most frame with the error message.

When an assertion helper like `failedTest` fails, the helper itself will be the top-most frame.
This means that, under normal circumstances, Hedgehog would display the error message
next to `failedTest`'s source code, and **not** next to the user's source code.

In order to force Hedgehog to display the error message next to the user's source code,
we use `withFrozenCallStack` to effectively remove the assertion helper from the callstack.

On a side note: the reason why we don't need to use `withFrozenCallStack` in Cleveland's
assertion helpers is because Cleveland only displays the bottom-most callstack
frame (i.e., the user's source code).
See: Note [Cleveland & callstacks] for more information.
-}

leftToShowPanic :: (PrettyShow e, Show e, HasCallStack) => Either e a -> a
leftToShowPanic = either (error . show) id

-- | Make a tuple with name without extra syntactic noise.
(?-) :: Text -> a -> (Text, a)
(?-) = (,)
infixr 0 ?-

----------------------------------------------------------------------------
-- Property
----------------------------------------------------------------------------

-- | A 'Property' that always fails with given message.
failedTest :: (HasCallStack, MonadTest m) => Text -> m ()
failedTest r =
  -- See: Note [Hedgehog & withFrozenCallStack]
  withFrozenCallStack $ annotate (toString r) >> failure

-- | A 'Property' that always succeeds.
succeededTest :: MonadTest m => m ()
succeededTest = success

-- | The 'Property' holds on `Left a`.
eitherIsLeft :: (Show b, MonadTest m, HasCallStack) => Either a b -> m ()
eitherIsLeft = \case
  Left _ -> succeededTest
  Right x ->
    -- See: Note [Hedgehog & withFrozenCallStack]
    withFrozenCallStack $ failedTest $ "expected Left, got Right (" <> Debug.show x <> ")"

-- | The 'Property' holds on `Right b`.
eitherIsRight :: (Show a, MonadTest m, HasCallStack) => Either a b -> m ()
eitherIsRight = \case
  Right _ -> succeededTest
  Left x ->
    -- See: Note [Hedgehog & withFrozenCallStack]
    withFrozenCallStack $ failedTest $ "expected Right, got Left (" <> Debug.show x <> ")"

-- | Benchmarks the given function and checks that the mean time to evaluate to weak head
-- normal form is under the given amount of time.
--
-- This test fails if the benchmark takes longer than 30 seconds to run.
meanTimeUpperBoundProp
  :: (KnownDivRat unit Second, KnownUnitName unit, HasCallStack)
  => Time unit -> (a -> b) -> a -> Property
meanTimeUpperBoundProp upperBound run arg =
  -- See: Note [Hedgehog & withFrozenCallStack]
  withFrozenCallStack $
    checkReport upperBound $ whnf run arg

-- | Benchmarks the given function and checks that the mean time to evaluate to
-- normal form is under the given amount of time.
--
-- This test aborts and fails if the benchmark takes longer than 120 seconds to run.
meanTimeUpperBoundPropNF
  :: (KnownDivRat unit Second, KnownUnitName unit, HasCallStack, NFData b)
  => Time unit -> (a -> b) -> a -> Property
meanTimeUpperBoundPropNF upperBound run arg =
  -- See: Note [Hedgehog & withFrozenCallStack]
  withFrozenCallStack $
    checkReport upperBound $ nf run arg

checkReport
  :: (KnownDivRat unit Second, KnownUnitName unit)
  => HasCallStack => Time unit -> Benchmarkable -> Property
checkReport upperBound benchmarkable =
  withTests 1 $ property $
    evalIO runBench >>= \case
      Nothing -> failedTest "Expected benchmark to complete within 120 seconds."
      Just report ->
        let mean = sec . realToFrac @Double @RatioNat . estPoint . anMean $ reportAnalysis report
        in  if mean < toUnit @Second upperBound
              then succeededTest
              else failedTest $
                "Expected mean estimate to be under "
                <> show upperBound
                <> ", but was "
                <> display mean
  where
    runBench = timeout (minute 2) $
      benchmarkWith' (defaultConfig { verbosity = Quiet }) benchmarkable

    display :: Time Second -> Text
    display n = case n of
      (toUnit @Minute -> x) | x > minute 1 -> format x
      (toUnit @Second -> x) | x > sec 1 -> format x
      (toUnit @Millisecond -> x) | x > ms 1 -> format x
      (toUnit @Microsecond -> x) | x > mcs 1 -> format x
      (toUnit @Nanosecond -> x) | x > ns 1 -> format x
      _ -> format (toUnit @Picosecond n)

    format :: forall unit. KnownUnitName unit => Time unit -> Text
    format n =
      toText @String $ printf "%.4f%s"
        (realToFrac @RatioNat @Double $ unTime n)
        (unitNameVal @unit)

----------------------------------------------------------------------------
-- Generator
----------------------------------------------------------------------------

-- | Generates an @a@ and a @b@ and wraps them in a tuple.
genTuple2 :: MonadGen m => m a -> m b -> m (a, b)
genTuple2 = liftA2 (,)

-- | Construct a hedgehog generator from a generator relying on 'MonadRandom'.
--
-- This neither shrinks nor generates values in a reasonable order,
-- use only when such properties are justified for your type.
genRandom :: MonadGen m => (forall n. MonadRandom n => n a) -> m a
genRandom gen = evalRand gen . mkStdGen <$> Gen.prune Gen.enumBounded

-- | Run the given generator deterministically, by fixing its size and seed.
runGen :: HasCallStack => Range.Size -> Word64 -> Gen a -> a
runGen size seed genT =
  let tree = runGenT size (Seed.from seed) genT
      node = fromMaybe discardedErr $ runIdentity $ runMaybeT $ runTreeT tree
      discardedErr = error $
        "Generator could not produce a value for size "
        <> show (Range.unSize size) <> " and seed " <> show seed
  in  nodeValue node

----------------------------------------------------------------------------
-- Roundtrip
----------------------------------------------------------------------------

-- | This 'TestTree' contains a property based test for conversion from
-- some @x@ to some @y@ and back to @x@ (it should successfully return
-- the initial @x@).
roundtripTree
  :: forall x y err.
     ( Show x
     , Show y
     , Show err
     , Typeable x
     , Eq x
     , Eq err
     , HasCallStack
     )
  => Gen x
  -> (x -> y)
  -> (y -> Either err x)
  -> TestTree
roundtripTree genX xToY yToX = testProperty typeNameX prop
  where
    typeNameX = show $ typeRep (Proxy @x)
    prop :: Property
    prop = property $ do
      x <- forAll genX
      tripping x xToY yToX

-- | Test that one doc item goes before another doc item in generated
-- documentation.
assertGoesBefore
  :: forall d1 d2.
      (DocItem d1, DocItem d2)
  => Proxy d1 -> Proxy d2 -> Assertion
assertGoesBefore dp1 dp2 =
  unless (p1 < p2) $
    assertFailure $
      "Doc item " <> show (typeRep dp1) <> " with position " <> pretty p1 <> " \
      \goes before doc item " <> show (typeRep dp2) <> " with position " <> pretty p2
  where
    p1 = docItemPosition @d1
    p2 = docItemPosition @d2

-- | Test that one doc item goes before another doc item in generated
-- documentation.
goesBefore
  :: forall d1 d2.
      (DocItem d1, DocItem d2)
  => Proxy d1 -> Proxy d2 -> TestTree
goesBefore dp1 dp2 = testCase testName (assertGoesBefore dp1 dp2)
  where
  testName = "`" +| show @Text (typeRep dp1) |+ "` should come before `"
    +| show @Text (typeRep dp2) |+ "`"

----------------------------------------------------------------------------
-- Pretty-printing
----------------------------------------------------------------------------

formatValue :: forall t. SingI t => T.Value t -> Builder
formatValue v = "" +| build v |+ " of type " +| demote @t |+ ""

formatSomeValue :: (forall t. c t => SingI t) => SomeConstrainedValue c -> Builder
formatSomeValue = foldConstrained formatValue

-- | Derive a 'Show' instance for a type using a custom "show" function.
-- Note: the `show`n value is paren-wrapped iff it's a subexpression,
-- just as an ordinary `Show` would do.
data ShowWith a = ShowWith (a -> String) a

instance Eq a => Eq (ShowWith a) where
  ShowWith _ x == ShowWith _ y = x == y

instance Show (ShowWith a) where
  showsPrec d (ShowWith f a) = Text.Show.showParen (d > app_prec) $ Text.Show.showString $ f a
    where
      app_prec = 10

-- | Derive a 'Buildable' instance for a type using 'show'.
newtype Showing a = Showing a
  deriving stock Eq
  deriving newtype Show

instance Show a => Buildable (Showing a) where
  build (Showing a) = build (Debug.show @Text a)

----------------------------------------------------------------------------
-- Time
----------------------------------------------------------------------------

-- | Round the given time to the nearest whole number of the given unit,
-- not smaller than the given time.
--
-- @
-- ceilingUnit (sec 2.0) == sec 2
-- ceilingUnit (sec 2.1) == sec 3
-- ceilingUnit (sec 2.9) == sec 3
-- @
ceilingUnit :: forall (unit :: Rat) . Time unit -> Time unit
ceilingUnit = time . (% 1) . ceiling . unTime

-- | Converts the given time to a number with fixed-precision (in the given time unit).
--
-- @
-- timeToFixed (sec 1.234) == (1.2            :: Deci)
-- timeToFixed (sec 1.234) == (1.234          :: Milli)
-- timeToFixed (sec 1.234) == (1.234000000000 :: Pico)
-- @
timeToFixed
  :: forall precision unit
   . HasResolution precision
  => Time unit -> Fixed precision
timeToFixed = fromRational @(Fixed precision) . toRational . unTime

-- | Converts the given time to a 'NominalDiffTime'.
timeToNominalDiffTime :: KnownDivRat unit Second => Time unit -> NominalDiffTime
timeToNominalDiffTime =
  secondsToNominalDiffTime . timeToFixed . toUnit @Second

----------------------------------------------------------------------------
-- Bytes
----------------------------------------------------------------------------

stripOptional0x :: Text -> Text
stripOptional0x h = T.stripPrefix "0x" h ?: h

fromHex :: Text -> Either Text ByteString
fromHex hexText =
  let errMsg hex = "Invalid hex: \"" <> hex <> "\""
  in maybeToRight (errMsg hexText) . decodeHex . stripOptional0x $ hexText

parseAddressFromHex :: Text -> Either Text Address
parseAddressFromHex = fromHex >=> first pretty . parseAddressRaw

----------------------------------------------------------------------------
-- Traversals
----------------------------------------------------------------------------

{- | Version of 'mapM' generalized with 'L.each'.

Example:

> (addr1, addr2, addr3) <- mapEach newAddress ("test1", "test2", "test3")

This is more type-safe than simple 'mapM' since lists do not remember
their length in types.
-}
mapEach
  :: (L.Each s t a b, Applicative m)
  => (a -> m b) -> s -> m t
mapEach = L.traverseOf L.each

-- | Version of 'mapEach' with arguments flipped.
forEach
  :: (L.Each s t a b, Applicative m)
  => s -> (a -> m b) -> m t
forEach = L.forOf L.each