-- | This module can be used in order to create golden tests for Aeson -- serializers and deserializers -- -- @ -- {-\# LANGUAGE TemplateHaskell \#-} -- -- import Hedeghog -- import qualified Hedeghog.Gen as Gen -- import qualified Hedeghog.Golden.Aeson as Aeson -- -- -- | A golden test for characters in the hex range -- prop_char_golden :: Property -- prop_char_golden = Aeson.goldenProperty Gen.hexit -- -- tests :: IO Bool -- tests = checkParallel $$discover -- @ module Hedgehog.Golden.Aeson ( -- * Golden tests for generators goldenProperty , goldenProperty' ) where import Prelude import Control.Monad (forM_) import Control.Monad.IO.Class (MonadIO(..)) import Data.Algorithm.Diff (Diff(..), getDiff) import Data.Aeson (FromJSON, ToJSON, (.=), (.:)) import qualified Data.Aeson as Aeson (eitherDecodeStrict) import qualified Data.Aeson.Types as Aeson import Data.Aeson.Encode.Pretty (Config(..), Indent(..), encodePretty', defConfig) import qualified Data.ByteString.Lazy as ByteString (toStrict) import Data.Proxy (Proxy(..)) import Data.Sequence (Seq) import Data.Text (Text) import qualified Data.Text as Text (intercalate, lines, pack, unpack) import qualified Data.Text.Encoding as Text (decodeUtf8, encodeUtf8) import qualified Data.Text.IO as Text (readFile, writeFile) import Data.Typeable (Typeable, typeRep) import Hedgehog (Gen, Property, PropertyT, Seed(..)) import Hedgehog (success) import qualified Hedgehog.Internal.Seed as Seed import Hedgehog.Internal.Source import Hedgehog.Internal.Property (Log(..), Property(..), PropertyConfig(..)) import Hedgehog.Internal.Property (defaultConfig, evalM, failWith, writeLog) import Hedgehog.Golden.Sample (genSamples) import Hedgehog.Golden.Types (GoldenTest(..), ValueGenerator, ValueReader) import qualified Hedgehog.Golden.Internal.Source as Source import System.Directory (createDirectoryIfMissing, doesFileExist) -- | Run a golden test on the given generator -- -- This will create a file in @golden/.json.new@ in case it does not -- exist. If it does exist - the golden tests will be run against it -- goldenProperty :: forall a . HasCallStack => Typeable a => FromJSON a => ToJSON a => Gen a -> Property goldenProperty = withFrozenCallStack $ goldenProperty' "golden/" -- | Same as 'goldenProperty' but allows specifying the directory -- goldenProperty' :: forall a . HasCallStack => Typeable a => FromJSON a => ToJSON a => FilePath -> Gen a -> Property goldenProperty' baseDir gen = withFrozenCallStack $ Property defaultConfig { propertyTestLimit = 1, propertyShrinkLimit = 0 } . evalM $ goldenTest baseDir gen >>= \case NewFile fileName valGen -> do newGoldenFile baseDir fileName valGen ExistingFile fileName valGen readerM -> existingGoldenFile baseDir fileName valGen readerM newGoldenFile :: HasCallStack => FilePath -> FilePath -> ValueGenerator -> PropertyT IO () newGoldenFile basePath fileName gen = do seed <- Seed.random -- Create new file liftIO $ do createDirectoryIfMissing False basePath Text.writeFile (fileName <> ".new") . Text.intercalate "\n" . gen $ seed -- Annotate output writeLog . Footnote $ "New golden file generated in: " <> fileName failWith Nothing "No previous golden file exists" existingGoldenFile :: HasCallStack => FilePath -> FilePath -> ValueGenerator -> Maybe ValueReader -> PropertyT IO () existingGoldenFile basePath fp gen reader = getSeedAndLines >>= \case Right (seed, existingLines) -> let comparison = getDiff existingLines $ gen seed hasDifference = any $ \case Both _ _ -> False First _ -> True Second _ -> True runDecodeTest = forM_ reader $ \r -> either (failWith Nothing . (<>) "Failed to deserialize with error: " . Text.unpack) (const success) (r . Text.intercalate "\n" $ existingLines) in if hasDifference comparison then do liftIO $ do createDirectoryIfMissing False basePath Text.writeFile (fp <> ".gen") . Text.intercalate "\n" . gen $ seed writeLog . Footnote $ "Different file generated as: " <> fp <> ".gen" failWith Nothing . Text.unpack . Text.intercalate "\n" $ [ "Failed in serialization comparison" , "" , Source.yellow "Difference when generating: " <> Text.pack fp , printDifference comparison ] else runDecodeTest Left err -> failWith Nothing $ "Couldn't read previous golden file (" <> fp <> ") because: " <> err where getSeedAndLines = liftIO $ do fileContents <- Text.readFile fp pure . fmap (, Text.lines fileContents) . decodeSeed $ fileContents printDifference :: [Diff Text] -> Text printDifference = Text.intercalate "\n" . Source.wrap Source.boxTop Source.boxBottom . addLineNumbers 1 . renderDiff where renderDiff :: [Diff Text] -> [Diff Text] renderDiff = fmap $ \case Both text _ -> Both (" " <> text) (" " <> text) First text -> First $ Source.red $ "-" <> text Second text -> Second $ Source.green $ "+" <> text addLineNumbers :: Int -> [Diff Text] -> [Text] addLineNumbers _ [] = [] addLineNumbers i (d : ds) = case d of Both text _ -> Source.addLineNumber i text : addLineNumbers (i + 1) ds First text -> Source.addLineNumber i text : addLineNumbers i ds Second text -> Source.addLineNumber i text : addLineNumbers (i + 1) ds goldenTest :: forall a m . Typeable a => FromJSON a => ToJSON a => MonadIO m => FilePath -> Gen a -> m GoldenTest goldenTest prefix gen = let typeName = show . typeRep $ Proxy @a fileName = prefix <> typeName <> ".json" aesonValueGenerator seed = Text.lines . encodeGolden seed $ genSamples seed gen aesonValueReader t = either (Left . Text.pack) (const $ Right ()) $ Aeson.eitherDecodeStrict (Text.encodeUtf8 t) >>= decodeGolden @a in liftIO $ do fileExists <- doesFileExist fileName pure $ if fileExists then ExistingFile fileName aesonValueGenerator (Just aesonValueReader) else NewFile fileName aesonValueGenerator encodeGolden :: ToJSON a => Seed -> Seq a -> Text encodeGolden seed samples = let aesonSeed (Seed value gamma) = Aeson.object [ "value" .= value, "gamma" .= gamma ] encodePretty = Text.decodeUtf8 . ByteString.toStrict . encodePretty' defConfig { confIndent = Spaces 2 , confCompare = compare } in encodePretty $ Aeson.object [ "seed" .= aesonSeed seed, "samples" .= Aeson.toJSON samples ] decodeSeed :: Text -> Either String Seed decodeSeed text = let getSeed :: Aeson.Object -> Either String Seed getSeed = Aeson.parseEither $ \obj -> do value <- obj .: "seed" >>= (.: "value") gamma <- obj .: "seed" >>= (.: "gamma") pure $ Seed value gamma in Aeson.eitherDecodeStrict (Text.encodeUtf8 text) >>= getSeed decodeGolden :: FromJSON a => Aeson.Object -> Either String (Seed, Seq a) decodeGolden = Aeson.parseEither $ \obj -> do value <- obj .: "seed" >>= (.: "value") gamma <- obj .: "seed" >>= (.: "gamma") samples <- obj .: "samples" pure (Seed value gamma, samples)