packages feed

moonlight-pale-0.1.0.0: src-test/Moonlight/Pale/Test/Assertions.hs

{-| Typed-result, numeric-tolerance, and collection assertions. -}
module Moonlight.Pale.Test.Assertions
  ( expectRight,
    expectRightWithLabel,
    expectSome,
    assertApproxEqual,
    assertNonEmpty,
    assertSubsetOf,
    withResult,
  )
where

import Data.Set (Set)
import Data.Set qualified as Set
import GHC.Stack (HasCallStack)
import Moonlight.Pale.Test.Core
  ( Tolerance,
    ToleranceObstruction,
    absoluteTolerance,
    relativeTolerance,
  )
import Test.Tasty.HUnit (Assertion, assertBool, assertFailure)

expectedRightMessage :: Show e => e -> String
expectedRightMessage err = "expected Right, got Left: " <> show err

expectRight :: (HasCallStack, Show e) => Either e a -> IO a
expectRight = either (assertFailure . expectedRightMessage) pure

expectRightWithLabel :: (HasCallStack, Show e) => String -> Either e a -> IO a
expectRightWithLabel label =
  either (\err -> assertFailure (label <> ": " <> expectedRightMessage err)) pure

expectSome :: HasCallStack => String -> Maybe a -> IO a
expectSome label result =
  case result of
    Nothing -> assertFailure ("expected Just for " <> label <> ", got Nothing")
    Just val -> pure val

assertApproxEqual ::
  HasCallStack =>
  String ->
  Either ToleranceObstruction Tolerance ->
  Double ->
  Double ->
  Assertion
assertApproxEqual label toleranceResult expected actual =
  case toleranceResult of
    Left toleranceObstruction ->
      assertFailure
        (label <> ": invalid tolerance: " <> show toleranceObstruction)
    Right tolerance
      | isNaN expected || isNaN actual ->
          assertFailure
            (label <> ": NaN is never approximately equal; expected " <> show expected <> ", got " <> show actual)
      | isInfinite expected || isInfinite actual ->
          assertBool
            (label <> ": infinities must be exactly equal; expected " <> show expected <> ", got " <> show actual)
            (expected == actual)
      | otherwise ->
          let absoluteError = abs (actual - expected)
              relativeScale = max (abs expected) (abs actual)
              relativeLimit = relativeTolerance tolerance * relativeScale
              acceptedLimit = max (absoluteTolerance tolerance) relativeLimit
              relativeError =
                if relativeScale == 0
                  then 0
                  else absoluteError / relativeScale
           in assertBool
                ( label
                    <> ": expected "
                    <> show expected
                    <> ", got "
                    <> show actual
                    <> "; absolute error "
                    <> show absoluteError
                    <> " (limit "
                    <> show (absoluteTolerance tolerance)
                    <> "), relative error "
                    <> show relativeError
                    <> " (limit "
                    <> show (relativeTolerance tolerance)
                    <> ")"
                )
                (absoluteError <= acceptedLimit)

assertNonEmpty :: HasCallStack => [a] -> Assertion
assertNonEmpty xs =
  assertBool "expected non-empty list" (not (null xs))

assertSubsetOf :: (HasCallStack, Ord a, Show a) => Set a -> Set a -> Assertion
assertSubsetOf subset superset =
  let missing = Set.difference subset superset
   in assertBool
        ("expected subset, missing: " <> show (Set.toList missing))
        (Set.null missing)

withResult :: (HasCallStack, Show e) => Either e a -> (a -> Assertion) -> Assertion
withResult result check =
  either (assertFailure . expectedRightMessage) check result