packages feed

skeletest-0.4.0: src/Skeletest/Internal/Spec/Tree.hs

{-# LANGUAGE DisambiguateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE NoFieldSelectors #-}

module Skeletest.Internal.Spec.Tree (
  -- * Spec interface
  Spec,
  SpecM,
  SpecTree (..),
  SpecTest (..),

  -- ** Entrypoint
  SpecRegistry,
  SpecInfo (..),
  pruneSpec,
  applyTestSelections,

  -- ** Defining a Spec
  describe,
  Testable (..),
  test,
  it,

  -- ** Modifiers
  MarkerXFail (..),
  xfail,
  MarkerSkip (..),
  skip,
  MarkerFocus (..),
  focus,
  MarkerManual (..),
  markManual,

  -- ** Markers
  IsMarker (..),
  withMarkers,
  withMarker,

  -- ** Internal API
  getSpecTrees,
  withSpecTrees,
  mapSpecTrees,
  traverseSpecTrees,
  getSpecTests,
  mapSpecTests,
  filterSpecTests,
  traverseSpecTests,
  mapSpecs,
  traverseSpecs,
) where

import Control.Monad (guard, (>=>))
import Control.Monad.Trans.Reader qualified as Trans
import Control.Monad.Trans.Writer (Writer, execWriter, tell)
import Data.Foldable qualified as Seq
import Data.Functor.Identity (runIdentity)
import Data.Maybe (catMaybes, mapMaybe)
import Data.Sequence qualified as Seq
import Data.Text (Text)
import Data.Text qualified as Text
import Skeletest.Assertions (Testable, runTestable)
import Skeletest.Internal.Markers (
  AnonMarker (..),
  IsMarker (..),
  SomeMarker (..),
 )
import Skeletest.Internal.TestRunner (TestResult)
import Skeletest.Internal.TestTargets (TestTarget, matchesTest)
import Skeletest.Internal.TestTargets qualified as TestTargets

type Spec = SpecM ()

newtype SpecM a = Spec (Writer [SpecTree] a)
  deriving (Functor, Applicative, Monad)

data SpecTree
  = SpecTree_Group
      { label :: Text
      , trees :: [SpecTree]
      }
  | SpecTree_Test SpecTest

data SpecTest = SpecTest
  { name :: Text
  , markers :: [SomeMarker]
  -- ^ Markers, in order from least to most recently applied.
  --
  -- >>> withMarker MarkerA . withMarker MarkerB $ test ...
  --
  -- will contain
  --
  -- >>> SpecTree_Test { testMarkers = [MarkerA, MarkerB] }
  , action :: IO TestResult
  }

getSpecTrees :: Spec -> [SpecTree]
getSpecTrees (Spec spec) = execWriter spec

withSpecTrees :: (Monad m) => ([SpecTree] -> m [SpecTree]) -> Spec -> m Spec
withSpecTrees f = fmap (Spec . tell) . f . getSpecTrees

-- | Traverse the tree with the given processing function.
--
-- To preprocess trees with @pre@ and postprocess with @post@:
--
-- >>> traverseSpecTrees (\go -> post <=< mapM go <=< pre) spec
traverseSpecTrees ::
  forall m.
  (Monad m) =>
  ((SpecTree -> m SpecTree) -> [SpecTree] -> m [SpecTree]) ->
  Spec ->
  m Spec
traverseSpecTrees f = withSpecTrees go
 where
  go :: [SpecTree] -> m [SpecTree]
  go = f recurseGroups

  recurseGroups = \case
    group@SpecTree_Group{} -> do
      trees' <- go group.trees
      pure group{trees = trees'}
    stest@SpecTree_Test{} -> pure stest

-- | Map the tree with the given processing function.
--
-- To preprocess trees with @pre@ and postprocess with @post@:
--
-- >>> mapSpecTrees (\go -> post . map go . pre) spec
mapSpecTrees ::
  ((SpecTree -> SpecTree) -> [SpecTree] -> [SpecTree]) ->
  Spec ->
  Spec
mapSpecTrees f = runIdentity . traverseSpecTrees (\go -> pure . f (runIdentity . go))

getSpecTests :: Spec -> [SpecTest]
getSpecTests = Seq.toList . execWriter . traverseSpecTests go
 where
  go stest = tell (Seq.singleton stest) *> pure stest

traverseSpecTests :: (Monad m) => (SpecTest -> m SpecTest) -> Spec -> m Spec
traverseSpecTests f = traverseSpecTrees $ \go ->
  traverse $
    go >=> \case
      group@SpecTree_Group{} -> pure group
      SpecTree_Test test_ -> SpecTree_Test <$> f test_

mapSpecTests :: (SpecTest -> SpecTest) -> Spec -> Spec
mapSpecTests f = runIdentity . traverseSpecTests (pure . f)

filterSpecTests :: (SpecTest -> Bool) -> Spec -> Spec
filterSpecTests f = mapSpecTrees $ \go -> filter f' . map go
 where
  f' = \case
    SpecTree_Group{} -> True
    SpecTree_Test test_ -> f test_

{----- Entrypoint -----}

type SpecRegistry = [SpecInfo]

data SpecInfo = SpecInfo
  { specPath :: FilePath
  , spec :: Spec
  }

traverseSpecs :: (Applicative f) => (Spec -> f Spec) -> SpecRegistry -> f SpecRegistry
traverseSpecs f = traverse $ \info -> (\spec -> info{spec = spec}) <$> f info.spec

mapSpecs :: (Spec -> Spec) -> SpecRegistry -> SpecRegistry
mapSpecs f = runIdentity . traverseSpecs (pure . f)

-- | Remove specs with no tests.
pruneSpec :: SpecRegistry -> SpecRegistry
pruneSpec = mapMaybe $ \info -> do
  let spec = mapSpecTrees (\go -> filter (not . isEmptySpec) . map go) info.spec
  guard $ (not . null . getSpecTrees) spec
  pure info{spec = spec}
 where
  isEmptySpec = \case
    SpecTree_Group _ [] -> True
    _ -> False

applyTestSelections :: TestTarget -> SpecInfo -> SpecInfo
applyTestSelections selections info = info{spec = applySelections info.spec}
 where
  applySelections = (`Trans.runReader` []) . traverseSpecTrees apply

  apply go = mapMaybeM $ \case
    group@SpecTree_Group{label} -> Just <$> Trans.local (<> [label]) (go group)
    stest@(SpecTree_Test test_) -> do
      groups <- Trans.ask
      let attrs =
            TestTargets.TestAttrs
              { path = info.specPath
              , identifier = groups <> [test_.name]
              , markers = [Text.pack $ getMarkerName m | SomeMarker m <- test_.markers]
              }
      pure $
        if matchesTest selections attrs
          then Just stest
          else Nothing

  mapMaybeM f = fmap catMaybes . mapM f

{----- Defining a Spec -----}

-- | The entity or concept being tested.
describe :: String -> Spec -> Spec
describe name = runIdentity . withSpecTrees (pure . (: []) . mkGroup)
 where
  mkGroup trees =
    SpecTree_Group
      { label = Text.pack name
      , trees
      }

test :: (Testable m) => String -> m () -> Spec
test name t = Spec $ tell [mkTest]
 where
  mkTest =
    SpecTree_Test $
      SpecTest
        { name = Text.pack name
        , markers = []
        , action = runTestable t
        }

-- | Define an IO-based test.
--
-- Should typically be written to be read as full sentences in traditional BDD style:
-- https://en.wikipedia.org/wiki/Behavior-driven_development.
--
-- @
-- describe \"User\" $ do
--   it "can be checked for equality" $ do
--     user1 `shouldBe` user1
-- @
it :: String -> IO () -> Spec
it = test

{----- Modifiers -----}

-- | Mark the given spec as expected to fail with the given description.
-- Fails tests if they unexpectedly pass.
--
-- Can be selected with the marker @@xfail@
xfail :: String -> Spec -> Spec
xfail = withMarker . MarkerXFail . Text.pack

-- | Skip all tests in the given spec with the given description.
--
-- Can be selected with the marker @@skip@
skip :: String -> Spec -> Spec
skip = withMarker . MarkerSkip . Text.pack

-- | If at least one test is focused, skip all unfocused tests.
--
-- This definition includes a WARNING so that CI errors if it's accidentally
-- committed (assuming CI runs with @-Wall -Werror@).
--
-- @since 0.3.4
focus :: Spec -> Spec
focus = withMarker MarkerFocus
{-# WARNING in "x-focused-tests" focus "focus should only be used in development" #-}

-- | Mark tests as tests that should only be run when explicitly specified on the command line.
markManual :: Spec -> Spec
markManual = withMarker MarkerManual

{----- Markers -----}

newtype MarkerXFail = MarkerXFail Text
  deriving (Show)

instance IsMarker MarkerXFail where
  getMarkerName _ = "xfail"

newtype MarkerSkip = MarkerSkip Text
  deriving (Show)

instance IsMarker MarkerSkip where
  getMarkerName _ = "skip"

data MarkerFocus = MarkerFocus
  deriving (Show)

instance IsMarker MarkerFocus where
  getMarkerName _ = "focus"

data MarkerManual = MarkerManual
  deriving (Show)

instance IsMarker MarkerManual where
  getMarkerName _ = "manual"

-- | Adds the given marker to all the tests in the given spec.
--
-- Useful for selecting tests from the command line or identifying tests in hooks
withMarker :: (IsMarker a) => a -> Spec -> Spec
withMarker m = mapSpecTrees (\go -> map (addMarker . go))
 where
  marker = SomeMarker m
  addMarker = \case
    group@SpecTree_Group{} -> group
    SpecTree_Test test_ -> SpecTree_Test test_{markers = marker : test_.markers}

-- | Adds the given names as plain markers to all tests in the given spec.
--
-- See 'getMarkerName'.
withMarkers :: [String] -> Spec -> Spec
withMarkers = foldr (\name acc -> withMarker (AnonMarker name) . acc) id