I have many IO
-based actions, one of the simplest being the following:
-- Loop.hs
module Loop where
import System.Console.ANSI (setCursorPosition)
type Pos = (Int, Int)
setCursorPosition' :: Pos -> IO ()
setCursorPosition' = uncurry setCursorPosition
At which point, starting from the one above, I decided to write these functions in terms of a type constraint implemented by IO
, rather than hard-coding IO
, as suggested by this answer.
So what I did consisted of
FakeIO
typeclass
and its trivial implementation for IO
:
-- Interfaces.hs
module Interfaces where
import qualified System.Console.ANSI as ANSI (setCursorPosition)
class FakeIO m where
setCursorPosition :: Int -> Int -> m ()
instance FakeIO IO where
setCursorPosition = ANSI.setCursorPosition
setCursorPosition'
to use this interface:
-- Loop.hs
module Loop where
import Interfaces
type Pos = (Int, Int)
setCursorPosition' :: FakeIO m => Pos -> m ()
setCursorPosition' = uncurry setCursorPosition
This resulted in the program to still work fine (via cabal run
), testifying that the "refactoring" was correct.
But when I tried leveraging this refactoring for the purpose of testing, I got stuck. What I did was writing the following test:
-- test/Main.hs
module Main where
import Control.Monad (unless)
import System.Exit (exitFailure)
import MTLPrelude (State, execState, modify')
import Test.QuickCheck
import Loop
import Interfaces
data MockTerminal = MockTerminal {
pos :: (Int, Int)
} deriving Eq
instance FakeIO (State MockTerminal) where
setCursorPosition y x = modify' $ \m -> MockTerminal { pos = (y, x) }
main :: IO ()
main = do
result <- quickCheckResult tCenter
unless (isSuccess result) exitFailure
tCenter :: Bool
tCenter = (setCursorPosition' (1,1))
`execState` MockTerminal { pos = (0,0)}
== MockTerminal { pos = (1,1) }
which fails to compile (via cabal test
) because
error: [GHC-39999]
• No instance for ‘snakegame-0.1.0.0:Loop:Interfaces.FakeIO
(StateT MockTerminal Identity)’
arising from a use of ‘setCursorPosition'’
• In the first argument of ‘execState’, namely
‘(setCursorPosition' (1, 1))’
In the first argument of ‘(==)’, namely
‘(setCursorPosition' (1, 1))
`execState` MockTerminal {pos = (0, 0)}’
In the expression:
(setCursorPosition' (1, 1)) `execState` MockTerminal {pos = (0, 0)}
== MockTerminal {pos = (1, 1)}
|
41 | tCenter = (setCursorPosition' (1,1))
| ^^^^^^^^^^^^^^^^^^
which I don't understand, because instance FakeIO (State MockTerminal)
should be precisely the snakegame-0.1.0.0:Loop:Interfaces.FakeIO (StateT MockTerminal Identity)
instance that the compiler is claiming doesn't exist.
Furthermore, if I change the test to use setCursorPosition 1 1
instead of setCursorPosition' (1,1)
, it compiles and passes, revealing that the instance
is making its job indeed.
So something must be off with how that instance
integrates with the definition of setCursorPosition'
.
I've shrunk the example to the following 4 files:
$ tree !(dist-newstyle)
cabal.project [error opening dir]
LICENSE [error opening dir]
Session.vim [error opening dir]
snakegame.cabal [error opening dir]
src
├── Interfaces.hs
├── Loop.hs
└── Main.hs
test
└── Main.hs
2 directories, 8 files
of which:
-- src/Main.hs
module Main where
import Loop
main :: IO ()
main = setCursorPosition' (1,1)
-- src/Loop.hs
module Loop (setCursorPosition') where
import Interfaces
type Pos = (Int, Int)
setCursorPosition' :: FakeIO m => Pos -> m ()
setCursorPosition' = uncurry setCursorPosition
-- test/Main.hs
module Main where
import Control.Monad (unless)
import System.Exit (exitFailure)
import MTLPrelude (State, execState, modify')
import Test.QuickCheck
import Loop
import Interfaces
data MockTerminal = MockTerminal {
pos :: (Int, Int)
} deriving Eq
instance FakeIO (State MockTerminal) where
setCursorPosition y x = modify' $ \m -> MockTerminal { pos = (y, x) }
putChar _ = modify' id
main :: IO ()
main = do
result <- quickCheckResult tCenter
unless (isSuccess result) exitFailure
tCenter :: Bool
tCenter = (setCursorPosition' (1,1))
`execState` MockTerminal { pos = (0,0)}
== MockTerminal { pos = (1,1)}
cabal-version: 3.0
name: snakegame
version: 0.1.0.0
common common
default-language: GHC2024
build-depends: base >= 4.19.1.0
, ansi-terminal
, mtl-prelude
common warnings
ghc-options: -Wall
executable snakegame
import: warnings, common
main-is: Main.hs
other-modules: Loop
, Interfaces
hs-source-dirs: src
library Loop
import: warnings, common
exposed-modules: Loop
hs-source-dirs: src
library Interfaces
import: warnings, common
exposed-modules: Interfaces
hs-source-dirs: src
test-suite Test
import: warnings, common
type: exitcode-stdio-1.0
main-is: Main.hs
build-depends: QuickCheck
, Interfaces
, Loop
hs-source-dirs: test
packages: .
with-compiler: ghc-9.10.1
Your Haskell source code is all fine. The problem you're running into is from Cabal. To fix it, do the following:
app
Main.hs
from src/
to app/
snakegame.cabal
with the following:cabal-version: 3.0
name: snakegame
version: 0.1.0.0
common common
default-language: GHC2024
build-depends: base >= 4.19.1.0
, ansi-terminal
, mtl-prelude
common warnings
ghc-options: -Wall
executable snakegame
import: warnings, common
main-is: Main.hs
build-depends: snakegame
hs-source-dirs: app
library
import: warnings, common
exposed-modules: Loop
, Interfaces
hs-source-dirs: src
test-suite Test
import: warnings, common
type: exitcode-stdio-1.0
main-is: Main.hs
build-depends: QuickCheck
, snakegame
hs-source-dirs: test
The problem was that you were saying that Loop
and Interfaces
each belonged to multiple libraries, so you were ending up with multiple copies of them, and the FakeIO
that you declared the instance
for wasn't the same copy as the one in the constraint in setCursorPosition'
.