packages feed

snippet-0.1.0.3: test/SnippetSpec.hs

{-# OPTIONS_GHC -Wno-orphans #-}

module SnippetSpec
  ( spec
  ) where

import Prelude

import Control.Monad (guard)
import Data.Aeson (FromJSON, ToJSON)
import Data.Foldable (traverse_)
import Data.List.NonEmpty (nonEmpty)
import Data.Semigroup (Endo (..))
import Data.Text (Text, pack)
import Data.Text qualified as T
import Data.Yaml qualified as Yaml
import GHC.Generics (Generic)
import Prettyprinter (defaultLayoutOptions, layoutPretty, reAnnotate)
import Prettyprinter.Render.Terminal qualified as ANSI
import Prettyprinter.Render.Text qualified as Text
import Snippet
import Snippet.Pretty (annToAnsi, prettySnippet)
import System.Environment (lookupEnv)
import System.FilePath (dropTrailingPathSeparator, splitPath)
import System.FilePath.Glob (globDir1)
import Test.Hspec

spec :: Spec
spec = do
  gs <- runIO $ loadGoldens "test-cases"
  traverse_ goldenSpec gs

instance FromJSON Region

instance ToJSON Region

data Golden = Golden
  { path :: FilePath
  , fixture :: GoldenFixture
  }

data GoldenFixture = GoldenFixture
  { input :: GoldenInput
  , expected :: Maybe GoldenSnippet
  }
  deriving stock (Generic)
  deriving anyclass (FromJSON, ToJSON)

data GoldenInput = GoldentInput
  { region :: Region
  , source :: Text
  , sourceStartLine :: Maybe Int
  }
  deriving stock (Generic)
  deriving anyclass (FromJSON, ToJSON)

data GoldenSnippet = GoldenSnippet
  { text :: Text
  , ansi :: Text
  }
  deriving stock (Generic)
  deriving anyclass (FromJSON, ToJSON)

loadGoldens :: FilePath -> IO [Golden]
loadGoldens base = do
  yamls <- globDir1 "**/*.yaml" base
  traverse loadGolden yamls

loadGolden :: FilePath -> IO Golden
loadGolden path = do
  fixture <- Yaml.decodeFileThrow path
  pure $ Golden {path, fixture}

goldenSpec :: Golden -> Spec
goldenSpec g = go $ map dropTrailingPathSeparator $ splitPath g.path
 where
  go = \case
    [] -> it g.path $ goldenIt g
    [p] -> it p $ goldenIt g
    (p : ps) -> context p $ go ps

goldenIt :: Golden -> Expectation
goldenIt g = do
  update <- lookupEnvYes "SNIPPET_TEST_GOLDEN_UPDATE"
  actual <- decodeGolden g

  let mExpected = do
        guard $ not update
        g.fixture.expected

  case mExpected of
    Nothing -> encodeGolden g actual
    Just expected -> do
      -- If both are wrong, we'd like to work on the text failure first
      actual.text `shouldBe` expected.text
      actual.ansi `shouldBe` expected.ansi

decodeGolden :: Golden -> IO GoldenSnippet
decodeGolden g = do
  snippet <-
    maybe err (pure . mkSnippet)
      $ nonEmpty
      $ T.lines
      $ g.fixture.input.source

  let simple =
        layoutPretty defaultLayoutOptions
          $ reAnnotate annToAnsi
          $ prettySnippet snippet

  pure
    $ GoldenSnippet
      { text = Text.renderStrict simple
      , ansi = replaceEscapes $ ANSI.renderStrict simple
      }
 where
  mkSnippet =
    snippetFromContents
      g.fixture.input.region
      (pack g.path)
      g.fixture.input.sourceStartLine

  err = do
    expectationFailure "Fixture region was invalid"
    error "unreachable"

encodeGolden :: Golden -> GoldenSnippet -> IO ()
encodeGolden g gs =
  Yaml.encodeFile g.path $ g.fixture {expected = Just gs}

replaceEscapes :: Text -> Text
replaceEscapes = appEndo $ foldMap (uncurry toEndo) escapes
 where
  toEndo :: Text -> Text -> Endo Text
  toEndo a b = Endo $ T.replace a b

  escapes :: [(Text, Text)]
  escapes =
    [ ("\ESC[0;36m", "{c}")
    , ("\ESC[0;34m", "{b}")
    , ("\ESC[0;32m", "{g}")
    , ("\ESC[0;31m", "{r}")
    , ("\ESC[0;1m", "{B}")
    , ("\ESC[0m", "{x}")
    ]

lookupEnvYes :: String -> IO Bool
lookupEnvYes e = maybe False isYes <$> lookupEnv e
 where
  isYes x =
    any
      ((normalize x ==) . normalize)
      [ "1"
      , "y"
      , "yes"
      , "true"
      , "on"
      ]

  normalize :: String -> Text
  normalize = T.toCaseFold . T.strip . pack