Add regeneration for Golden files

There are a few things going on here that are slightly orthogonal, but are all uncontroversial progress that had to happen eventually: test runner options, logging, factoring out duplication in `TestResult`...
This commit is contained in:
George Thomas 2026-01-08 01:25:24 +00:00
parent 39c0dd3de9
commit 12e30085b0
2 changed files with 87 additions and 32 deletions

View File

@ -24,7 +24,7 @@ main = do
-- terminalWidth <- Terminal.Size.width <<$>> Terminal.Size.size -- TODO this doesn't work in GHCID or GHCIWatch... -- terminalWidth <- Terminal.Size.width <<$>> Terminal.Size.size -- TODO this doesn't work in GHCID or GHCIWatch...
terminalWidth <- pure $ Just 62 terminalWidth <- pure $ Just 62
T.putStrLn $ drawTree $ getTestTree tests T.putStrLn $ drawTree $ getTestTree tests
TL.putStrLn . displayTestResultsConsole terminalWidth =<< runTests () tests TL.putStrLn . displayTestResultsConsole terminalWidth =<< runTests TestRunnerOpts{regenerateGoldenFiles = False} () tests
tests :: TestTree IO () tests :: TestTree IO ()
tests = tests =

View File

@ -71,6 +71,7 @@ module Pre (
TestName, TestName,
getTestTree, getTestTree,
displayTestResultsConsole, displayTestResultsConsole,
TestRunnerOpts (..),
runTests, runTests,
assertEqual, assertEqual,
assert, assert,
@ -99,8 +100,10 @@ import Control.Exception (SomeException, evaluate)
import Control.Monad import Control.Monad
import Control.Monad.Catch (MonadCatch, MonadThrow, try) import Control.Monad.Catch (MonadCatch, MonadThrow, try)
import Control.Monad.Except import Control.Monad.Except
import Control.Monad.Loops import Control.Monad.Loops hiding (firstM)
import Control.Monad.Reader
import Control.Monad.State import Control.Monad.State
import Control.Monad.Writer
import Data.Bifunctor import Data.Bifunctor
import Data.Bool import Data.Bool
import Data.Char import Data.Char
@ -128,7 +131,7 @@ import Data.Text.Lazy qualified as TL
import Data.Time import Data.Time
import Data.Traversable import Data.Traversable
import Data.Tree hiding (drawTree) import Data.Tree hiding (drawTree)
import Data.Tuple.Extra ((&&&)) import Data.Tuple.Extra (firstM, (&&&))
import Data.Void import Data.Void
import Data.Word import Data.Word
import GHC.Generics (Generic) import GHC.Generics (Generic)
@ -136,6 +139,8 @@ import GHC.TypeNats (KnownNat, Nat, type (+))
import Linear (V2 (..)) import Linear (V2 (..))
import Safe import Safe
import System.Console.ANSI import System.Console.ANSI
import System.Directory
import System.FilePath
import Text.Megaparsec hiding (Pos, State, Stream, many, some) import Text.Megaparsec hiding (Pos, State, Stream, many, some)
import Text.Megaparsec.Char import Text.Megaparsec.Char
import Text.Megaparsec.Char.Lexer (decimal) import Text.Megaparsec.Char.Lexer (decimal)
@ -268,7 +273,16 @@ data TestCase m input output where
TestCase :: (NFData output) => (input -> Test m output) -> TestCase m input output TestCase :: (NFData output) => (input -> Test m output) -> TestCase m input output
TestCaseLazy :: (input -> Test m output) -> TestCase m input output TestCaseLazy :: (input -> Test m output) -> TestCase m input output
newtype Test m a = Test (ExceptT TestFailure m a) newtype Test m a
= Test
( ExceptT
TestFailure
( WriterT
[TestLogItem]
(ReaderT TestRunnerOpts m)
)
a
)
deriving newtype deriving newtype
( Functor ( Functor
, Applicative , Applicative
@ -277,6 +291,8 @@ newtype Test m a = Test (ExceptT TestFailure m a)
, MonadThrow , MonadThrow
, MonadCatch , MonadCatch
, MonadError TestFailure , MonadError TestFailure
, MonadWriter [TestLogItem]
, MonadReader TestRunnerOpts
) )
-- | See `testLazy` for avoiding the `NFData` constraint. -- | See `testLazy` for avoiding the `NFData` constraint.
@ -289,14 +305,19 @@ It doesn't force the output before completion, which means that reported timings
testLazy :: Text -> (input -> Test m output) -> [TestTree m output] -> TestTree m input testLazy :: Text -> (input -> Test m output) -> [TestTree m output] -> TestTree m input
testLazy n f = TestTree (TestName n) $ TestCaseLazy f testLazy n f = TestTree (TestName n) $ TestCaseLazy f
data TestResult data TestResult = TestResult
= Pass TestName NominalDiffTime [TestResult] { name :: TestName
| Fail TestName TestFailure , logs :: [TestLogItem]
deriving (Show) , result :: Either TestFailure (NominalDiffTime, [TestResult])
}
data TestLogItem
= LogRegeneratedGolden
data TestFailure data TestFailure
= ExceptionFailure SomeException = ExceptionFailure SomeException
| AssertionFailure Text | AssertionFailure Text
| GoldenMissing
| GoldenFailure {expected :: Text, actual :: Text} | GoldenFailure {expected :: Text, actual :: Text}
newtype TestName = TestName Text newtype TestName = TestName Text
@ -310,18 +331,31 @@ displayTestResultsConsole terminalWidth testResult =
where where
displayResult indent = displayResult indent =
(TL.replicate (fromIntegral indent) " " <>) . \case (TL.replicate (fromIntegral indent) " " <>) . \case
Pass (TestName name) dt children -> TestResult{name = TestName name, logs, result} ->
TL.fromStrict (header Green '✓' name indent (Just dt)) case result of
<> TL.concat (map (displayResult (indent + 1)) children) Right (dt, children) ->
Fail (TestName name) e -> TL.fromStrict (header Green '✓' name indent (Just dt) <> displayLogs)
TL.fromStrict $ <> TL.concat (map (displayResult (indent + 1)) children)
header Red '✗' name indent Nothing Left e ->
<> setColour Vivid Red TL.fromStrict $
<> paddedAllLines (T.replicate (indent * 2) " ") case e of header Red '✗' name indent Nothing
ExceptionFailure ex -> T.show ex <> displayLogs
AssertionFailure t -> T.stripEnd t <> setColour Vivid Red
GoldenFailure{expected, actual} -> <> indentAllLines indent case e of
"Expected:\n" <> T.stripEnd expected <> "\nActual:\n" <> T.stripEnd actual ExceptionFailure ex -> T.show ex
AssertionFailure t -> T.stripEnd t
GoldenMissing -> "Golden file missing"
GoldenFailure{expected, actual} ->
"Expected:\n" <> T.stripEnd expected <> "\nActual:\n" <> T.stripEnd actual
where
displayLogs =
setColour Dull Magenta
<> indentAllLines
indent
( flip foldMap logs \case
LogRegeneratedGolden -> "Created golden file"
)
<> setColour Dull Magenta
header colour icon name indent time = header colour icon name indent time =
setColour Vivid colour setColour Vivid colour
<> T.singleton icon <> T.singleton icon
@ -345,6 +379,7 @@ displayTestResultsConsole terminalWidth testResult =
time time
<> "\n" <> "\n"
paddedAllLines p = T.unlines . map (p <>) . T.lines paddedAllLines p = T.unlines . map (p <>) . T.lines
indentAllLines indent = paddedAllLines $ T.replicate (indent * 2) " "
showTime (nominalDiffTimeToSeconds -> MkFixed duration) = showTime (nominalDiffTimeToSeconds -> MkFixed duration) =
-- SI prefixes, and always exactly 2 decimal places, or 3 if there's no prefix -- SI prefixes, and always exactly 2 decimal places, or 3 if there's no prefix
T.show res T.show res
@ -373,16 +408,21 @@ displayTestResultsConsole terminalWidth testResult =
sgr = T.pack . setSGRCode sgr = T.pack . setSGRCode
setColour d c = sgr [SetColor Foreground d c] setColour d c = sgr [SetColor Foreground d c]
runTests :: (MonadIO m, MonadCatch m) => a -> TestTree m a -> m TestResult data TestRunnerOpts = TestRunnerOpts
runTests r0 (TestTree name tc ts) = { regenerateGoldenFiles :: Bool
}
runTests :: (MonadIO m, MonadCatch m) => TestRunnerOpts -> a -> TestTree m a -> m TestResult
runTests opts r0 (TestTree name tc ts) =
let Test t = Control.Monad.Catch.try $ runTest tc let Test t = Control.Monad.Catch.try $ runTest tc
in runExceptT t >>= \case in runReaderT (runWriterT (runExceptT t)) opts
Left e -> >>= fmap (\(result, logs) -> TestResult{name, logs, result}) . firstM \case
pure $ Fail name e Left e ->
Right (Left e) -> pure $ Left e
pure $ Fail name $ ExceptionFailure e Right (Left e) ->
Right (Right (r, dt)) -> pure $ Left $ ExceptionFailure e
Pass name dt <$> for ts (runTests r) Right (Right (r, dt)) ->
Right . (dt,) <$> for ts (runTests opts r)
where where
runTest = \case runTest = \case
TestCase f -> timed (liftIO . evaluate . DeepSeq.force) $ f r0 TestCase f -> timed (liftIO . evaluate . DeepSeq.force) $ f r0
@ -401,6 +441,21 @@ assert s b = if b then pure () else assertFailure s
assertFailure :: (Monad m) => Text -> Test m a assertFailure :: (Monad m) => Text -> Test m a
assertFailure = throwError . AssertionFailure assertFailure = throwError . AssertionFailure
golden :: (MonadIO m, MonadFail m) => FilePath -> Text -> Test m () golden :: (MonadIO m, MonadFail m) => FilePath -> Text -> Test m ()
golden p actual = do golden file actual = do
expected <- liftIO $ T.readFile p TestRunnerOpts{..} <- ask
if expected == actual then pure () else throwError $ GoldenFailure{expected, actual} exists <- liftIO $ doesFileExist file
if exists
then do
expected <- liftIO $ T.readFile file
if expected == actual then pure () else throwError $ GoldenFailure{expected, actual}
else do
if regenerateGoldenFiles
then
let parents = dropWhile null $ scanl (</>) "" $ splitDirectories $ takeDirectory file
in tell [LogRegeneratedGolden] >> liftIO do
for_ parents \dir -> do
parentExists <- liftIO $ doesDirectoryExist dir
when (not parentExists) $ createDirectory dir
T.writeFile file actual
else
throwError GoldenMissing