mockcat-1.0.0.0: test/Test/MockCat/THCompareSpec.hs
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE TypeFamilies #-}
{- HLINT ignore "Use fewer imports" -}
module Test.MockCat.THCompareSpec (spec) where
import Test.Hspec
import Language.Haskell.TH (pprint, Dec(..), Type(..), litE, stringL, listE, tupE)
import Language.Haskell.TH.Syntax (nameBase)
import Data.Char (isDigit, isLower)
import Data.List (foldl', sort, nub)
import Data.Maybe (mapMaybe)
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import qualified System.IO as SIO
import Test.MockCat.SharedSpecDefs
import Test.MockCat.TH (makeMock, makeAutoLiftMock, makePartialMock, makeAutoLiftPartialMock)
import Language.Haskell.TH (lookupTypeName, conT)
import Control.Monad (forM_, when)
normalize :: String -> String
normalize =
T.unpack
. squeezePunctuation
. T.unwords
. map stripVarSuffix
. T.words
. stripQualifiers
. T.pack
stripQualifiers :: T.Text -> T.Text
stripQualifiers =
stripPrefixes
(map
T.pack
[ "Test.MockCat.SharedSpecDefs."
, "Test.MockCat.MockT."
, "Test.MockCat.Internal.Builder."
, "Test.MockCat.Cons."
, "Test.MockCat.Param."
, "Control.Monad.IO.Class."
, "GHC.Types."
, "Data.Typeable.Internal."
, "GHC.Internal."
, "Base."
, "Test.MockCat.Verify."
, "Verify."
, "GHC.Show."
, "GHC.Classes."
, "Show."
, "Classes."
, "Internal."
])
where
stripPrefixes [] t = t
stripPrefixes (p : ps) t = stripPrefixes ps (T.replace p T.empty t)
stripVarSuffix :: T.Text -> T.Text
stripVarSuffix = T.pack . go Nothing . T.unpack
where
go _ [] = []
go prev (c : cs)
| c == '_' && maybe False isLower prev =
go prev (dropWhile isDigit cs)
| otherwise = c : go (Just c) cs
squeezePunctuation :: T.Text -> T.Text
squeezePunctuation =
applyReplacements
[ ("( ", "(")
, (" )", ")")
, (" ,", ",")
, ("[ ", "[")
, (" ]", "]")
, (" <" , "<")
, ("> ", ">")
, ("((:>) ", "(")
, ("((:>)", "(")
, ("(:>) ", "")
, ("(:>)", "")
]
. T.replace (T.pack "Head (Param") (T.pack "Head :>Param")
. T.replace (T.pack "Head :> Param") (T.pack "Head :>Param")
-- Fix: MockBuilder (Head :>Param r) r () should not become MockBuilder (Head :>Param r r ()
-- First fix the case where " r r ()" appears (missing closing paren)
. T.replace (T.pack ":>Param r r ()") (T.pack ":>Param r) r ()")
. T.replace (T.pack "Head :>Param r r ()") (T.pack "Head :>Param r) r ()")
. T.replace (T.pack "Param r r ()") (T.pack "Param r) r ()")
-- Then handle the general case of ") r ()" -> " r ()" but only if not already fixed
. T.replace (T.pack ") r ()") (T.pack " r ()")
where
applyReplacements reps txt =
foldl'
(\acc (from, to) -> T.replace (T.pack from) (T.pack to) acc)
txt
reps
-- generate at compile time to avoid running restricted TH actions in IO
-- store pretty-printed generated declarations as strings
generatedFinderStr :: String
generatedFinderStr = $(do decs <- makeAutoLiftPartialMock [t|Finder|]; litE (stringL (concatMap pprint decs)))
generatedFinderSigPairs :: [(String, String)]
generatedFinderSigPairs =
$(
do
decs <- makeAutoLiftPartialMock [t|Finder|]
let sigPairs =
[ (nameBase name, pprint ty)
| SigD name ty <- decs
]
listE
[ tupE [litE (stringL fn), litE (stringL sig)]
| (fn, sig) <- sigPairs
]
)
generatedFinderSigMap :: Map.Map String String
generatedFinderSigMap = Map.fromList generatedFinderSigPairs
generatedUserInputSigPairs :: [(String, String)]
generatedUserInputSigPairs =
$(
do
decs <- makeAutoLiftPartialMock [t|UserInputGetter|]
let sigPairs =
[ (nameBase name, pprint ty)
| SigD name ty <- decs
]
listE
[ tupE [litE (stringL fn), litE (stringL sig)]
| (fn, sig) <- sigPairs
]
)
generatedUserInputSigMap :: Map.Map String String
generatedUserInputSigMap = Map.fromList generatedUserInputSigPairs
generatedFileOpStr :: String
generatedFileOpStr = $(do decs <- makeAutoLiftPartialMock [t|FileOperation|]; litE (stringL (concatMap pprint decs)))
generatedTestClassStr :: String
generatedTestClassStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.TestClass"
case m of
Nothing -> fail "TestClass not found"
Just n -> do decs <- makeAutoLiftMock (conT n); litE (stringL (concatMap pprint decs))
)
generatedMultiApplyStr :: String
generatedMultiApplyStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.MultiApplyTest"
case m of
Nothing -> fail "MultiApplyTest not found"
Just n -> do decs <- makeAutoLiftMock (conT n); litE (stringL (concatMap pprint decs))
)
generatedExplicitStr :: String
generatedExplicitStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.ExplicitlyReturnMonadicValuesTest"
case m of
Nothing -> fail "ExplicitlyReturnMonadicValuesTest not found"
Just n -> do decs <- makeMock (conT n); litE (stringL (concatMap pprint decs))
)
generatedDefaultMethodStr :: String
generatedDefaultMethodStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.DefaultMethodTest"
case m of
Nothing -> fail "DefaultMethodTest not found"
Just n -> do decs <- makeAutoLiftMock (conT n); litE (stringL (concatMap pprint decs))
)
generatedAssocTypeStr :: String
generatedAssocTypeStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.AssocTypeTest"
case m of
Nothing -> fail "AssocTypeTest not found"
Just n -> do decs <- makeAutoLiftMock (conT n); litE (stringL (concatMap pprint decs))
)
generatedAssocSigPairs :: [(String, String)]
generatedAssocSigPairs =
$(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.AssocTypeTest"
case m of
Nothing -> fail "AssocTypeTest not found"
Just n -> do
decs <- makeAutoLiftMock (conT n)
let sigPairs =
[ (nameBase name, pprint ty)
| SigD name ty <- decs
]
listE
[ tupE [litE (stringL fn), litE (stringL sig)]
| (fn, sig) <- sigPairs
]
)
generatedAssocSigMap :: Map.Map String String
generatedAssocSigMap = Map.fromList generatedAssocSigPairs
generatedParamThreeStr :: String
generatedParamThreeStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.ParamThreeMonad"
case m of
Nothing -> fail "ParamThreeMonad not found"
Just n -> do
let targetType =
AppT
(AppT (ConT n) (ConT ''Int))
(ConT ''Bool)
decs <- makeAutoLiftMock (pure targetType)
litE (stringL (concatMap pprint decs))
)
generatedUserInputStr :: String
generatedUserInputStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.UserInputGetter"
case m of
Nothing -> fail "UserInputGetter not found"
Just n -> do decs <- makeAutoLiftPartialMock (conT n); litE (stringL (concatMap pprint decs))
)
generatedExplicitPartialStr :: String
generatedExplicitPartialStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.ExplicitlyReturnMonadicValuesPartialTest"
case m of
Nothing -> fail "ExplicitlyReturnMonadicValuesPartialTest not found"
Just n -> do decs <- makePartialMock (conT n); litE (stringL (concatMap pprint decs))
)
-- additional generated declarations for classes produced in `TypeClassTHSpec.hs`
generatedVar2_1SubStr :: String
generatedVar2_1SubStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.MonadVar2_1Sub"
case m of
Nothing -> fail "MonadVar2_1Sub not found"
Just n -> do decs <- makeAutoLiftMock (conT n); litE (stringL (concatMap pprint decs))
)
generatedVar2_2SubStr :: String
generatedVar2_2SubStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.MonadVar2_2Sub"
case m of
Nothing -> fail "MonadVar2_2Sub not found"
Just n -> do decs <- makeAutoLiftMock (conT n); litE (stringL (concatMap pprint decs))
)
generatedVar3_1SubStr :: String
generatedVar3_1SubStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.MonadVar3_1Sub"
case m of
Nothing -> fail "MonadVar3_1Sub not found"
Just n -> do decs <- makeAutoLiftMock (conT n); litE (stringL (concatMap pprint decs))
)
generatedVar3_2SubStr :: String
generatedVar3_2SubStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.MonadVar3_2Sub"
case m of
Nothing -> fail "MonadVar3_2Sub not found"
Just n -> do decs <- makeAutoLiftMock (conT n); litE (stringL (concatMap pprint decs))
)
generatedVar3_3SubStr :: String
generatedVar3_3SubStr = $(
do
m <- lookupTypeName "Test.MockCat.SharedSpecDefs.MonadVar3_3Sub"
case m of
Nothing -> fail "MonadVar3_3Sub not found"
Just n -> do decs <- makeAutoLiftMock (conT n); litE (stringL (concatMap pprint decs))
)
partialMockSpecPath :: FilePath
partialMockSpecPath = "test/Test/MockCat/PartialMockSpec.hs"
typeClassSpecPath :: FilePath
typeClassSpecPath = "test/Test/MockCat/TypeClassSpec.hs"
-- extract textual instance context for handwritten module
extractHandwrittenInstanceCxts :: FilePath -> String -> IO [String]
extractHandwrittenInstanceCxts fp className = do
src <- SIO.readFile fp
pure $ extractInstanceHeaders src className
spec :: Spec
spec = describe "TH generated vs handwritten instances" do
let cases =
[ ("Finder partial mock instance matches handwritten", partialMockSpecPath, "Finder", generatedFinderStr)
, ("FileOperation mock instance matches handwritten", partialMockSpecPath, "FileOperation", generatedFileOpStr)
, ("UserInputGetter partial mock instance matches handwritten", partialMockSpecPath, "UserInputGetter", generatedUserInputStr)
, ("ExplicitlyReturnMonadicValuesPartialTest instance matches handwritten", partialMockSpecPath, "ExplicitlyReturnMonadicValuesPartialTest", generatedExplicitPartialStr)
, ("TypeClass: TestClass instance matches handwritten", typeClassSpecPath, "TestClass", generatedTestClassStr)
, ("TypeClass: MultiApplyTest instance matches handwritten", typeClassSpecPath, "MultiApplyTest", generatedMultiApplyStr)
, ("TypeClass: ExplicitlyReturnMonadicValuesTest instance matches handwritten", typeClassSpecPath, "ExplicitlyReturnMonadicValuesTest", generatedExplicitStr)
, ("TypeClass: DefaultMethodTest instance matches handwritten", typeClassSpecPath, "DefaultMethodTest", generatedDefaultMethodStr)
, ("TypeClass: AssocTypeTest instance matches handwritten", typeClassSpecPath, "AssocTypeTest", generatedAssocTypeStr)
, ("TypeClass: ParamThreeMonad instance matches handwritten", typeClassSpecPath, "ParamThreeMonad", generatedParamThreeStr)
, ("MonadVar2_1Sub instance matches handwritten", typeClassSpecPath, "MonadVar2_1Sub", generatedVar2_1SubStr)
, ("MonadVar2_2Sub instance matches handwritten", typeClassSpecPath, "MonadVar2_2Sub", generatedVar2_2SubStr)
, ("MonadVar3_1Sub instance matches handwritten", typeClassSpecPath, "MonadVar3_1Sub", generatedVar3_1SubStr)
, ("MonadVar3_2Sub instance matches handwritten", typeClassSpecPath, "MonadVar3_2Sub", generatedVar3_2SubStr)
, ("MonadVar3_3Sub instance matches handwritten", typeClassSpecPath, "MonadVar3_3Sub", generatedVar3_3SubStr)
]
forM_ cases \(desc, manualPath, className, generatedStr) ->
it desc $
assertInstancesEquivalent manualPath className generatedStr
it "generated helper signatures do not contain duplicated Typeable constraints" do
let allSigMaps =
[ generatedFinderSigMap,
generatedUserInputSigMap
]
extractTypeableTargets :: String -> [String]
extractTypeableTargets s =
let go acc txt =
let (_, rest) = T.breakOn (T.pack "Typeable") txt
in if T.null rest
then acc
else
let afterTypeable = T.drop (T.length (T.pack "Typeable")) rest
afterTrim = T.stripStart afterTypeable
tok = T.strip $ T.takeWhile (\c -> c /= ',' && c /= ')' && c /= '=') afterTrim
rest' = afterTypeable
in go (acc ++ [T.unpack tok]) rest'
in go [] (T.pack s)
checkMap m =
forM_ (Map.toList m) \(_fn, sig) -> do
let nsig = normalizeSignature sig
targets = extractTypeableTargets nsig
length targets `shouldBe` length (nub targets)
mapM_ checkMap allSigMaps
describe "Partial mock helper signatures match handwritten" do
it "_findIds signature matches handwritten" $
assertHelperSigMatches partialMockSpecPath generatedFinderSigMap "_findIds"
it "_toUserInput signature omits redundant ResolvableParamsOf constraint" do
let genSig =
Map.lookup "_toUserInput" generatedUserInputSigMap
case genSig of
Nothing ->
expectationFailure "TH generated signature not found: _toUserInput"
Just sig ->
normalizeSignature sig
`shouldSatisfy` not . T.isInfixOf (T.pack "ResolvableParamsOf") . T.pack
it "_produce signature matches handwritten" $
assertHelperSigMatches typeClassSpecPath generatedAssocSigMap "_produce"
-- helper to extract generated instance headers from a pre-pprinted string
extractGeneratedInstanceCxtsFromStr :: String -> String -> [String]
extractGeneratedInstanceCxtsFromStr = extractInstanceHeaders
extractInstanceHeaders :: String -> String -> [String]
extractInstanceHeaders input className =
let txt = T.pack input
classTxt = T.pack className
insts = drop 1 $ T.splitOn (T.pack "instance") txt
mkHeader body =
let (beforeWhere, _) = T.breakOn (T.pack "where") body
(beforeBrace, _) = T.breakOn (T.pack "{") beforeWhere
header = T.strip beforeBrace
headerWithoutQualifiers = stripQualifiers header
headPart =
let (_ctxPart, rest) = T.breakOn (T.pack "=>") headerWithoutQualifiers
in if T.null rest
then headerWithoutQualifiers
else T.strip $ T.drop 2 rest
actualClassName =
case T.words headPart of
[] -> Nothing
(tok : _) ->
let cleaned = T.dropAround (`elem` "()") tok
in if T.null cleaned then Nothing else Just cleaned
containsMockT = T.pack "MockT" `T.isInfixOf` headerWithoutQualifiers
in case actualClassName of
Just cls
| cls == classTxt && containsMockT ->
Just $ normalize $ "instance " ++ T.unpack headerWithoutQualifiers
_ -> Nothing
in mapMaybe mkHeader insts
assertInstancesEquivalent :: FilePath -> String -> String -> Expectation
assertInstancesEquivalent manualPath className generatedStr = do
hand <- extractHandwrittenInstanceCxts manualPath className
let gen = extractGeneratedInstanceCxtsFromStr generatedStr className
sortedHand = sort hand
sortedGen = sort gen
when (null hand) $ expectationFailure $
"Handwritten instance not found: " ++ className ++ " in " ++ manualPath
when (null gen) $ expectationFailure $
"TH generated instance not found: " ++ className
++ "\nGenerated result (prefix only):\n"
++ take 1000 generatedStr
sortedGen `shouldBe` sortedHand
extractHandwrittenFunctionSig :: FilePath -> String -> IO (Maybe String)
extractHandwrittenFunctionSig fp funName = do
src <- SIO.readFile fp
let linesText = T.lines (T.pack src)
isSignatureLine line =
let trimmed = T.stripStart line
in (T.pack funName <> T.pack " ::") `T.isPrefixOf` trimmed
continuation line =
case T.uncons line of
Just (c, _) -> c == ' ' || c == '\t'
Nothing -> False
collect [] = Nothing
collect (line:rest)
| isSignatureLine line =
let cont = takeWhile continuation rest
in Just $ T.unpack $ T.unlines (line : cont)
| otherwise = collect rest
pure $ collect linesText
stripForallClause :: String -> String
stripForallClause sig =
let txt = T.pack sig
(preForall, rest) = T.breakOn (T.pack "forall") txt
in
if T.null rest
then sig
else
let afterForall = T.drop 1 $ T.dropWhile (/= '.') rest
in T.unpack (preForall <> afterForall)
normalizeSignature :: String -> String
normalizeSignature =
T.unpack
. T.strip
. T.pack
. dropFunctionName
. stripForallClause
. replaceDoubleParens
. normalize
-- collapse duplicated closing parens introduced by normalization quirks
replaceDoubleParens :: String -> String
replaceDoubleParens = T.unpack . T.replace (T.pack "))") (T.pack ")") . T.pack
dropFunctionName :: String -> String
dropFunctionName sig =
let txt = T.pack sig
(_, rest) = T.breakOn (T.pack "::") txt
in
if T.null rest
then sig
else T.unpack $ T.drop 2 rest
assertHelperSigMatches ::
FilePath ->
Map.Map String String ->
String ->
Expectation
assertHelperSigMatches manualPath generatedSigMap funName = do
manualSigM <- extractHandwrittenFunctionSig manualPath funName
manualSig <-
case manualSigM of
Nothing -> do
expectationFailure $
"Handwritten signature not found: "
++ funName
++ " in "
++ manualPath
pure ""
Just sig -> pure sig
generatedSig <-
case Map.lookup funName generatedSigMap of
Nothing -> do
expectationFailure $
"TH generated signature not found: "
++ funName
pure ""
Just sig -> pure sig
normalizeSignature generatedSig `shouldBe` normalizeSignature manualSig