-
Notifications
You must be signed in to change notification settings - Fork 4
Description
I've been porting a codebase from monad-mock where we've been using the MonadTransControl instance. That's not available for HMock. Currently I've got an orphan instance of it that's definitely not safe in general, but that works in the places where I use it. But I think it might be possible to provide a function that's basically liftWith specialized to MockT, that could be used safely.
To illustrate, here's the function in question, and the code I'm testing it with:
import qualified Test.HMock.Internal.State as HMockI
-- And a bunch of other imports
-- | Basically `liftWith`
liftMockWith
:: forall m a
. ((forall n b . Coercible m n => MockT n b -> n b) -> m a)
-> MockT m a
liftMockWith run = HMockI.MockT $ ReaderT $ \state ->
let runArg :: forall n b . MockT n b -> n b
runArg =
let changeState :: HMockI.MockState m -> HMockI.MockState n
changeState (HMockI.MockState {..}) = HMockI.MockState
{ mockExpectSet = unsafeCoerce mockExpectSet
, mockDefaults = unsafeCoerce mockDefaults
, mockAllowUnexpected = unsafeCoerce mockAllowUnexpected
, mockSideEffects = unsafeCoerce mockSideEffects
, mockParent = unsafeCoerce mockParent
, ..
}
in flip runReaderT (changeState state) . HMockI.unMockT
in run runArg
-- Test it:
class Monad m => MonadFilesystem m where
readF :: FilePath -> m String
writeF :: FilePath -> String -> m ()
copyFile :: MonadFilesystem m => FilePath -> FilePath -> m ()
copyFile a b = readF a >>= writeF b
makeMockable [t|MonadFilesystem|]
spec :: Spec
spec =
it "Test mock" $ do
return () :: IO ()
runIdentityT $ do
return () :: IdentityT IO ()
HMock.runMockT $ do
return () :: HMock.MockT (IdentityT IO) ()
expect $ ReadF "foo.txt" |-> "contents"
expect $ WriteF "bar.txt" "contents" |-> ()
liftMockWith $ \run -> do
return () :: IdentityT IO ()
liftIO $ run $ do
copyFile "foo.txt" "bar.txt" :: MockT IO ()I've included some type annotations partly as a hint to the typechecker and partly to make it a bit clearer what's going on.
This works fine, despite the unsafeCoerce. We can also move one or both expect calls to directly above copyFile, inside the run block.
We can use the same basic function to implement an orphan instance of MonadTransControl. (With StT MockT a = a and restoreT = lift.) And then if we replace liftMockWith in that code with liftWith, there's still no problem. (At least not at -O0. There's a lot of edge cases I haven't tested thoroughly yet.)
But it doesn't work if we replace IdentityT with a version of itself that's been implemented with data instead of newtype. That is, instead of importing IdentityT, define
data IdentityT m a = IdentityT { runIdentityT :: m a }
deriving stock Functor
instance Applicative m => Applicative (IdentityT m) where
pure = IdentityT . pure
IdentityT f <*> IdentityT x = IdentityT $ f <*> x
instance Monad m => Monad (IdentityT m) where
IdentityT x >>= f = IdentityT $ x >>= runIdentityT . f
instance MonadIO m => MonadIO (IdentityT m) where
liftIO = IdentityT . liftIOand suddenly we get a segfault.
uncaught exception: ErrorCall
Wrong arguments: writeF "bar.txt" "\1099511628032zsh: segmentation fault (core dumped) cabal run $GHC_LINK_OPTS -O0 testnofork -- -m "Test mock"
Presumably we've gone from doing unsafeCoerce between things that are coercible, to between things that aren't. And if we go back to liftMockWith, using our data-based IdentityT, we get a comile error:
• Couldn't match representation of type ‘IdentityT IO’
with that of ‘IO’
arising from a use of ‘run’
The data constructor ‘ghc-prim-0.6.1:GHC.Types.IO’
of newtype ‘IO’ is not in scope
(For either version of IdentityT, and either liftWith or liftMockWith, we can remove the liftIO and it works. Then the type of the copyFile line is MockT (IdentityT IO) (), and we're coercing between a thing and itself.)
So a big question here is, is that unsafeCoerce actually safe? Ultimately the things we're coercing are all either TVar [Step {m/n}] or TVar (ExpectSet (Step {m/n})). Step has type role nominal, so coerce isn't allowed unless m ~ n, but that doesn't prove it's unsafe. I think the thing I'd be most worried about is it has a Typeable m constraint. But I don't currently know where that comes from or if it's a problem here.
If the unsafeCoerce actually is unsafe, then another option might be to restrict the type further, to m ~ n - at that point it looks similar to withMockT, I'm not sure what the relationship is between them but maybe withMockT already gives us everything we're going to get here.
liftMockWith :: ((forall a . MockT m a -> m a) -> m b)
withMockT :: MonadIO m => ((forall a . MockT m a -> m a) -> MockT m b) -> m b(It's also possible withMockT will be sufficient for what I want to do. I don't currently think so, but I haven't fully explored the possibility. edit: looked into it now, I'm pretty sure it won't.)
Sorry this is kind of rambling. I guess the main questions I'm wondering here are
- Does the
liftMockWithimplementation above seem safe? - Would you consider adding it?