packages feed

annotated-exception-0.3.0.4: test/Control/Exception/AnnotatedSpec.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeApplications #-}

{-# options_ghc -fno-warn-orphans -fno-warn-type-defaults #-}

module Control.Exception.AnnotatedSpec where

import Test.Hspec

import Control.Exception.Annotated
import qualified Control.Exception.Safe as Safe
import Data.Annotation
import Data.AnnotationSpec ()
import Data.List (dropWhileEnd, intersperse)
import Data.Maybe
import Data.Typeable
import GHC.Stack

instance Eq CallStack where
    a == b = show a == show b

deriving stock instance (Eq e) => Eq (AnnotatedException e)

data TestException = TestException
    deriving (Eq, Show, Exception)

instance Eq SomeException where
    SomeException (e0 :: e0) == SomeException (e1 :: e1) =
        typeOf e0 == typeOf e1 && show e0 == show e1

data TestDisplayException = TestDisplayException
    deriving (Eq, Show)

instance Exception TestDisplayException where
    displayException _ = "i am being displayed! :)"

pass :: Expectation
pass = pure ()

emptyAnnotation :: e -> AnnotatedException e
emptyAnnotation = pure

-- | GHC 9.10.1 introduced a regression that added an extra newline to the end
-- of the `displayException` output for `SomeException`.
--
-- See: https://gitlab.haskell.org/ghc/ghc/-/issues/25052
trimTrailingNewlines :: String -> String
trimTrailingNewlines = dropWhileEnd (== '\n')

-- | Join a list of lines with newlines.
--
-- Unlike `unlines`, this doesn't add a newline at the end of the string.
joinLines = concat . intersperse "\n"

spec :: Spec
spec = do
    describe "toException" $ do
        it "wraps inner in SomeException" $ do
            toException (AnnotatedException [] TestException)
                `shouldBe` do
                    SomeException
                        (AnnotatedException [] (SomeException TestException))
        it "flattens annotations" $ do
            let
                exn =
                    AnnotatedException ["hello"] $
                        AnnotatedException ["goodbye"] TestException
            toException exn
                `shouldBe` do
                    SomeException $
                        AnnotatedException ["hello", "goodbye"] (SomeException TestException)

    describe "displayException" $ do
        it "is identical on SomeException" $ do
            trimTrailingNewlines (displayException TestException)
                `shouldBe` trimTrailingNewlines (displayException (SomeException TestException))

        it "uses show and displayException if they're different" $ do
            displayException (AnnotatedException [] TestDisplayException)
                `shouldBe` joinLines
                    [ "! AnnotatedException !"
                    , "Underlying exception type: TestDisplayException"
                    , ""
                    , "displayException:"
                    , "\ti am being displayed! :)"
                    , ""
                    , "show:"
                    , "\tTestDisplayException"
                    , ""
                    , "(no callstack available)"
                    ]

        it "is reasonably nice to look at" $ do
            displayException (AnnotatedException [] TestException)
                `shouldBe` joinLines
                    [ "! AnnotatedException !"
                    , "Underlying exception type: TestException"
                    , ""
                    , "TestException"
                    , ""
                    , "(no callstack available)"
                    ]

        it "is reasonably nice to look at" $ do
            displayException (AnnotatedException [Annotation @String "asdf"] TestException)
                `shouldBe` joinLines
                    [ "! AnnotatedException !"
                    , "Underlying exception type: TestException"
                    , ""
                    , "TestException"
                    , ""
                    , "Annotations:"
                    , "\t * Annotation @[Char] \"asdf\""
                    , ""
                    , "(no callstack available)"
                    ]

        it "shows underlying exception type" $ do
            Left exn <- try $ throwWithCallStack (AnnotatedException [Annotation @String "asdf"] TestException)
            let result = joinLines
                    [ "! AnnotatedException !"
                    , "Underlying exception type: TestException"
                    , ""
                    , "TestException"
                    , ""
                    , "Annotations:"
                    , "\t * Annotation @[Char] \"asdf\""
                    , ""
                    , "CallStack (from HasCallStack):"
                    ]
            take (length result) (displayException (exn :: AnnotatedException TestException))
                `shouldBe` result

    describe "AnnotatedException can fromException a" $ do
        it "different type" $ do
            fromException (toException TestException)
                `shouldBe`
                    Just (emptyAnnotation TestException)

        it "SomeException" $ do
            fromException (SomeException TestException)
                `shouldBe`
                    Just (emptyAnnotation (SomeException TestException))

        it "nested AnnotatedException" $ do
            fromException (toException (emptyAnnotation (emptyAnnotation TestException)))
                `shouldBe`
                    Just (emptyAnnotation TestException)

        it "can i guess also parse into a nested Annotated" $ do
            fromException (toException (emptyAnnotation TestException))
                `shouldBe`
                    Just (emptyAnnotation (emptyAnnotation TestException))

        it "does not loop infinitely if the wrong type is selected" $ do
            fromException (toException TestException)
                `shouldNotBe`
                    Just (emptyAnnotation $ userError "uh oh")

    describe "throw" $ do
        it "wraps exceptions" $ do
            throw TestException
                `shouldThrow`
                    \(AnnotatedException _ TestException) ->
                        True

    describe "catch" $ do
        it "catches located exceptions" $ do
            Safe.throw TestException
                `catch`
                    \(AnnotatedException [] TestException) ->
                        pass

        it "catches regular exceptions" $ do
            Safe.throw TestException
                `catch`
                    \TestException ->
                        pass

        it "catches SomeException" $ do
            throw TestException
                `catch`
                    \(SomeException _) ->
                        pass

        it "catches located SomeExceptions" $ do
            throw TestException
                `catch`
                    \(AnnotatedException _ (_ :: SomeException)) ->
                        pass

        it "permits other types to pass through" $ do
            let action =
                    Safe.throw (userError "uh oh")
                        `Safe.catch`
                            \(AnnotatedException _ TestException) ->
                                expectationFailure "Should not catch"
            action
                `shouldThrow`
                    (userError "uh oh" ==)

        describe "includes a callstack location" $ do
            it "with an originally annotated exception" $ do
                let
                    action =
                        throw TestException
                            `catch`
                                \TestException ->
                                    throw TestException
                action
                    `Safe.catch`
                            \(e :: AnnotatedException TestException) -> do
                                annotations e
                                    `callStackFunctionNamesShouldBe`
                                        [ "throw"
                                        , "throw"
                                        , "catch"
                                        ]
            it "with a non-annotated original exception" $ do
                let
                    action =
                        Safe.throw TestException
                            `catch`
                                \TestException ->
                                    throw TestException
                action
                    `Safe.catch`
                            \(e :: AnnotatedException TestException) -> do
                                annotations e
                                    `callStackFunctionNamesShouldBe`
                                        [ "throw"
                                        , "catch"
                                        ]

    describe "catches" $ do
        it "has a callstack entry" $ do
            let
                action =
                    throw TestException
                        `catches`
                            [ Handler $ \TestException ->
                                throw TestException
                            ]
            action
                `Safe.catch`
                        \(e :: AnnotatedException TestException) -> do
                            annotations e
                                `callStackFunctionNamesShouldBe`
                                    [ "throw"
                                    , "throw"
                                    , "catches"
                                    ]

    describe "tryAnnotated" $ do
        let subject :: (Exception e, Exception e') => e -> IO (AnnotatedException e')
            subject exn = do
                Left exn' <- tryAnnotated (throw exn)
                pure exn'

        it "promotes to empty with no annotations" $ do
            exn <- subject TestException
            exn `shouldBeWithoutCallStackInAnnotations` AnnotatedException [] TestException

        it "preserves annotations" $ do
            exn <- subject $ AnnotatedException ["hello"] TestException
            exn `shouldBeWithoutCallStackInAnnotations` AnnotatedException ["hello"] TestException

        it "preserves annotations added via checkpoint" $ do
            Left exn <- tryAnnotated $ do
                checkpoint "hello" $ do
                    throw TestException
            exn `shouldBeWithoutCallStackInAnnotations`
                AnnotatedException ["hello"] TestException

        it "doesn't mess up if trying the wrong type" $ do
            let
                action = do
                    Left exn <- tryAnnotated $ do
                        checkpoint "hello" $ do
                            throw TestException
                    exn `shouldBe` AnnotatedException ["hello"] (userError "oh no")
            action `catch` \ann ->
                ann `shouldBeWithoutCallStackInAnnotations`
                    AnnotatedException ["hello"] TestException

    describe "throwWithCallstack" $ do
        it "includes a CallStack on the given exception" $ do
            throwWithCallStack TestException
                `shouldThrow`
                    isJust . annotatedExceptionCallStack @TestException
        describe "interaction with checkpointCallStack" $ do
            it "only has one CallStack" $ do
                let
                    action = do
                        checkpointCallStack $ do
                            throwWithCallStack TestException
                action
                    `Safe.catch` \(e :: AnnotatedException TestException) -> do
                        annotations e
                            `callStackFunctionNamesShouldBe`
                                [ "throwWithCallStack"
                                , "checkpointCallStack"
                                ]

    describe "try" $ do
        let subject :: (Exception e, Exception e') => e -> IO e'
            subject exn = do
                Left exn' <- try (throw exn)
                pure exn'

        describe "when throwing non-Annotated" $ do
            it "can add an empty annotation for a non-Annotated exception" $ do
                exn <- subject TestException
                exn `shouldBeWithoutCallStackInAnnotations` AnnotatedException [] TestException

            it "can catch a usual exception" $ do
                exn <- subject TestException
                exn `shouldBe` TestException

        describe "when throwing Annotated" $ do
            it "can catch a non-Annotated exception" $ do
                exn <- subject $ emptyAnnotation TestException
                exn `shouldBe` TestException

            it "can catch an Annotated exception" $ do
                exn <- subject TestException
                exn `shouldBeWithoutCallStackInAnnotations` emptyAnnotation TestException

        describe "when the wrong error is tried " $ do
            let
                boom :: IO a
                boom =
                    Safe.throwIO $ userError "uh oh"
            it "does not catch the exception" $ do
                let
                    scenario = do
                        eres <- try boom
                        case eres of
                            Left TestException ->
                                pure ()
                            Right () ->
                                pure ()
                scenario
                    `shouldThrow`
                        (\e -> userError "uh oh" == e) -- TestException

        describe "nesting behavior" $ do
            it "can catch at any level of nesting" $ do
                subject TestException
                    >>= (`shouldBeWithoutCallStackInAnnotations` emptyAnnotation TestException)
                subject TestException
                    >>= (`shouldBeWithoutCallStackInAnnotations` emptyAnnotation (emptyAnnotation TestException))
                subject TestException
                    >>= (`shouldBeWithoutCallStackInAnnotations` emptyAnnotation (emptyAnnotation (emptyAnnotation TestException)))

    describe "Safe.try" $ do
        it "can catch a located exception" $ do
            Left exn <- Safe.try (Safe.throw TestException)
            exn `shouldBe` emptyAnnotation TestException

        it "does not catch an AnnotatedException" $ do
            let action = do
                    Left exn <- Safe.try (Safe.throw $ emptyAnnotation TestException)
                    exn `shouldBe` TestException
            action `shouldThrow` (== emptyAnnotation TestException)

    describe "catches" $ do
        it "is exported" $ do
            let
                _x :: IO a -> [Handler IO a] -> IO a
                _x = catches
            pass


    describe "checkpoint" $ do
        it "adds annotations" $ do
            Left exn <- try (checkpoint "Here" (throw TestException))
            exn `shouldBeWithoutCallStackInAnnotations`
                AnnotatedException ["Here"] TestException

        it "adds two annotations" $ do
            Left exn <- try $ do
                checkpoint "Here" $ do
                    checkpoint "There" $ do
                        throw TestException
            exn `shouldBeWithoutCallStackInAnnotations`
                AnnotatedException ["Here", "There"] TestException

        it "adds three annotations" $ do
            Left exn <- try $
                checkpoint "Here" $
                checkpoint "There" $
                checkpoint "Everywhere" $
                throw TestException
            exn `shouldBeWithoutCallStackInAnnotations`
                AnnotatedException ["Here", "There", "Everywhere"] TestException

        it "caught exceptions are propagated" $ do
            eresp <- try $
                checkpoint "Here" $
                flip catch (\TestException -> pure "Hello") $
                checkpoint "There" $
                checkpoint "Everywhere" $
                throw TestException
            case eresp of
                Left (AnnotatedException _ TestException) ->
                    expectationFailure "Should not be an exception"
                Right resp ->
                    resp `shouldBe` "Hello"

        it "works with error calls" $ do
            eresp <- checkpoint "Yes" (error "Oh no") `catch`
                \(SomeException _) -> pure "bar"
            eresp `shouldBe` "bar"

        it "works with non-handled exceptions" $ do
            Left exn <- try $
                checkpoint "Lmao" $
                Safe.throw TestException
            exn `shouldBeWithoutCallStackInAnnotations`
                AnnotatedException ["Lmao"] TestException

        it "supports rethrowing" $ do
            Left exn <- try $
                checkpoint "A" $
                flip catch (\TestException -> throw TestException) $
                checkpoint "B" $
                throw TestException
            exn `shouldBeWithoutCallStackInAnnotations` AnnotatedException ["A", "B"] TestException

        it "handles CallStack nicely" $ do
            Left (AnnotatedException anns TestException) <- try $
                checkpoint (Annotation callStack) $
                    checkpoint (Annotation callStack) $
                        throwWithCallStack TestException

            anns `callStackFunctionNamesShouldBe`
                [ "throwWithCallStack"
                , "checkpoint"
                , "checkpoint"
                ]

        it "handles CallStack nicely when throwing" $ do
            Left (AnnotatedException anns TestException) <- try $
                throw TestException

            shouldHaveAtMostOneCallStack anns

        it "handles CallStack nicely when throwing manually-created AnnotatedException" $ do
            Left (AnnotatedException anns TestException) <- try $
                throw (AnnotatedException [Annotation callStack] TestException)

            shouldHaveAtMostOneCallStack anns

        it "handles CallStack nicely when rethrowing" $ do
            Left (AnnotatedException anns TestException) <- try $
                throw TestException
                    `Safe.catch` (\e -> throw (e :: AnnotatedException TestException))

            shouldHaveAtMostOneCallStack anns

        it "handles CallStack nicely when rethrowing manually-created AnnotatedException" $ do
            Left (AnnotatedException anns TestException) <- try $
                throw (AnnotatedException [Annotation callStack] TestException)
                    `Safe.catch` (\e -> throw (e :: AnnotatedException TestException))

            shouldHaveAtMostOneCallStack anns

    describe "HasCallStack behavior" $ do
        -- This section of the test suite exists to verify that some behavior
        -- acts how I expect it to. And/or learn how it behaves. Lol.
        let foo :: HasCallStack => IO ()
            foo = throwWithCallStack TestException
            bar :: HasCallStack => IO ()
            bar = foo
            baz :: HasCallStack => IO ()
            baz = bar

        it "should have source location" $ do
            foo
                `Safe.catch`
                    \(AnnotatedException anns TestException) -> do
                        anns
                            `callStackFunctionNamesShouldBe`
                                [ "throwWithCallStack"
                                , "foo"
                                ]

        it "appears to be throw-site first, then other entires" $ do
            baz
                `Safe.catch`
                    \(AnnotatedException anns TestException) -> do
                        anns
                            `callStackFunctionNamesShouldBe`
                                [ "throwWithCallStack"
                                , "foo"
                                , "bar"
                                , "baz"
                                ]

        describe "addCallstackToException" $ do
            let
                makeCs0 :: HasCallStack => IO CallStack
                makeCs0 = pure callStack
                makeCs1 :: HasCallStack => IO CallStack
                makeCs1 = pure callStack

            (cs0, cs1) <- runIO $ (,) <$> makeCs0 <*> makeCs1

            let baseException =
                    AnnotatedException [] TestException

            it "does not drop any other annotations" $ do
                addCallStackToException cs0 (AnnotatedException ["hello"] TestException)
                    `shouldBe`
                        AnnotatedException ["hello", Annotation cs0] TestException
            it "should add a CallStack to an empty AnnotatedException" $ do
                addCallStackToException cs0 baseException
                    `shouldBe`
                        AnnotatedException [Annotation cs0] TestException

            it "should not add a second CallStack to an AnnotatedException" $ do
                annotations (addCallStackToException cs1 (addCallStackToException cs0 baseException))
                    `shouldSatisfy` (1 ==) . length

            it "should merge CallStack as HasCallStack does" $ do
                [expectedAnnotation] <-
                    (undefined <$ foo) `Safe.catch`
                        \(AnnotatedException anns TestException) ->
                            pure anns
                Just expectedCallStack <- pure $ castAnnotation expectedAnnotation

                let
                    fooCS =
                        callStackFromFunctionName "foo"
                    throwWithCallStackCS =
                        callStackFromFunctionName "throwWithCallStack"
                    actualAnnotations =
                        annotations $
                            addCallStackToException fooCS  $
                                addCallStackToException
                                    throwWithCallStackCS
                                    baseException
                actualAnnotations
                    `callStackFunctionNamesShouldBe`
                        map fst (getCallStack expectedCallStack)

callStackFunctionNamesShouldBe :: HasCallStack => [Annotation] -> [String] -> IO ()
callStackFunctionNamesShouldBe anns names = do
    let ([callStack], []) = tryAnnotations anns
    map fst (getCallStack callStack)
        `shouldBe`
            names

shouldBeWithoutCallStackInAnnotations
    :: (HasCallStack, Eq e, Show e, Exception e)
    => AnnotatedException e
    -> AnnotatedException e
    -> IO ()
shouldBeWithoutCallStackInAnnotations (AnnotatedException exp e0) e1 = do
    AnnotatedException (filterCallStack exp) e0 `shouldBe` e1
  where
    filterCallStack anns =
        snd $ tryAnnotations @CallStack anns

shouldHaveAtMostOneCallStack :: HasCallStack => [Annotation] -> IO ()
shouldHaveAtMostOneCallStack anns =
    if (length (fst (tryAnnotations anns) :: [CallStack]) > 1)
    then expectationFailure $ "has too many callstacks: " ++ show anns
    else pure ()

callStackFromFunctionName :: String -> CallStack
callStackFromFunctionName str =
    fromCallSiteList [(str, undefined)]