unionmount-0.3.0.0: test/System/UnionMountSpec.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE NumericUnderscores #-}
module System.UnionMountSpec where
import Control.Monad.Logger.Extras (logToNowhere, runLoggerLoggingT)
import Data.LVar qualified as LVar
import Data.List (stripPrefix)
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Traversable (for)
import Relude.Unsafe qualified as Unsafe
import System.Directory (createDirectory)
import System.Directory.Recursive (getFilesRecursive)
import System.FilePath ((</>))
import System.FilePattern (FilePattern)
import System.UnionMount qualified as UM
import Test.Hspec
import UnliftIO (MonadUnliftIO)
import UnliftIO.Async (race_)
import UnliftIO.Concurrent (threadDelay)
import UnliftIO.Directory (removeFile, withCurrentDirectory)
import UnliftIO.Temporary (withSystemTempDirectory)
spec :: Spec
spec = do
-- TODO: Use QuickCheck to generate these.
describe "unionmount" $ do
it "basic" $ do
unionMountSpec $
one $
FolderMutation
Nothing
( do
writeFile "file1" "hello"
)
( do
writeFile "file1" "hello, again"
writeFile "file2" "another file"
)
it "deletion" $ do
unionMountSpec $
one $
FolderMutation
Nothing
( do
writeFile "file1" "hello"
writeFile "file2" "another file"
)
( do
writeFile "file1" "hello, again"
removeFile "file2"
)
it "multiple layers" $ do
unionMountSpec $
FolderMutation
Nothing
( do
writeFile "file1" "hello"
writeFile "file3" "hello"
)
( do
writeFile "file1" "hello, again"
)
:| [ FolderMutation
Nothing
( do
writeFile "file2" "another file"
)
( do
writeFile "file2" "another file, again"
writeFile "file3" "file3 is in first layer"
)
]
it "mount point layers" $ do
unionMountSpec $
FolderMutation
Nothing
( do
writeFile "file1" "hello"
writeFile "file3" "hello"
)
( do
writeFile "file1" "hello, again"
)
:| [ FolderMutation
(Just "foo")
( do
writeFile "file2" "another file"
)
( do
writeFile "file2" "another file, again"
writeFile "file3" "file3 is in first layer"
)
]
-- | Test `UM.unionMount` on a set of folders whose contents/mutations are
-- represented by a `FolderMutation`, and check that the resulting model is
-- equivalent to the state when these mutations are applied in normal IO context
-- (outside of unionmount).
unionMountSpec ::
-- | The folder mutations to test
UnionFolderMutations ->
Expectation
unionMountSpec folders = do
withUnionFolderMutations folders $ \tempDirs -> do
model <- LVar.empty
flip runLoggerLoggingT logToNowhere $ do
let layers = Set.fromList $ toList tempDirs <&> \(folder, path) -> (path, (path, _folderMountPoint folder))
(model0, patch) <- UM.unionMount layers allFiles ignoreNone mempty $ \change -> do
let files = Unsafe.fromJust $ Map.lookup () change
flip UM.chainM (Map.toList files) $ \(fp, act) -> do
case act of
UM.Delete -> pure $ Map.delete fp
UM.Refresh _ layerFiles -> do
contents <- for layerFiles $ \(tempDir, path) ->
readFileBS $ tempDir </> path
pure $ Map.insert fp contents
LVar.set model model0
race_
(patch $ LVar.set model)
(withPaddedThreadDelay 500_000 $ updateUnionFolderMutations tempDirs)
finalModel <- LVar.get model
expected <- runUnionFolderMutations folders
finalModel `shouldBe` expected
print expected
where
-- NOTE: These timings may not be enough on a slow system.
withPaddedThreadDelay :: (MonadUnliftIO m) => Int -> m () -> m ()
withPaddedThreadDelay padding action = do
-- Wait for the initial model to be loaded.
threadDelay padding
action
-- Wait for fsnotify to handle events
threadDelay padding
-- | Represent the mutation of a folder over time.
--
-- Initial state of the folder, along with the mutations to perform, both as IO
-- actions.
data FolderMutation = FolderMutation
{ -- Mount point: the subfolder in which files must be shifted.
_folderMountPoint :: Maybe FilePath,
-- | How to initialize the folder
_folderMutationInit :: IO (),
-- | IO operations to perform for updating the folder
_folderMutationUpdate :: IO ()
}
runFolderMutation :: FolderMutation -> IO (Map.Map FilePath ByteString)
runFolderMutation folder = do
withSystemTempDirectory "runFolderMutation" $ \tempDir -> do
withCurrentDirectory tempDir $ do
let withMountPointIfAny = case _folderMountPoint folder of
Nothing -> id
Just subdir -> \f -> do
-- Create the mount point
_ <- createDirectory subdir
withCurrentDirectory subdir f
withMountPointIfAny $ do
_folderMutationInit folder
_folderMutationUpdate folder
files <- getFilesRecursiveCurrentDir
Map.fromList <$> forM files (\f -> (f,) <$> readFileBS f)
where
getFilesRecursiveCurrentDir :: IO [FilePath]
getFilesRecursiveCurrentDir = do
fs <- getFilesRecursive "."
-- Remove the leading "./" from the file paths
pure $ fs <&> \f -> fromMaybe f $ stripPrefix "./" f
-- | A non-empty list of folder mutations that are meant to be unioned together.
type UnionFolderMutations = NonEmpty FolderMutation
runUnionFolderMutations :: UnionFolderMutations -> IO (Map.Map FilePath (NonEmpty ByteString))
runUnionFolderMutations folders =
Map.unionsWith (<>) . fmap (Map.map one) <$> traverse runFolderMutation folders
-- | Create a temp directory for each folder in the list, and call the handler.
--
-- Also initialize each folders. Use `updateUnionFolderMutations` to update the
-- folders. And `runUnionFolderMutations` to get the final state of the folders,
-- with values unioned as lists.
withUnionFolderMutations ::
(MonadUnliftIO m) =>
UnionFolderMutations ->
(NonEmpty (FolderMutation, FilePath) -> m a) ->
m a
withUnionFolderMutations folders f = do
withSystemTempDirectories folders $ \tempDirs -> do
forM_ tempDirs $ \(folder, tempDir) ->
liftIO $ withCurrentDirectory tempDir $ _folderMutationInit folder
f tempDirs
updateUnionFolderMutations ::
(MonadUnliftIO m) =>
NonEmpty (FolderMutation, FilePath) ->
m ()
updateUnionFolderMutations tempDirs = do
forM_ tempDirs $ \(folder, tempDir) ->
liftIO $ withCurrentDirectory tempDir $ _folderMutationUpdate folder
-- | Like `withSystemTempDirectory`, but for multiple temp directories.
withSystemTempDirectories ::
(MonadUnliftIO m) =>
-- | Create a temp directory for each tag in this list.
NonEmpty tag ->
-- | The handler is passed the temp directory along with the associated tag.
(NonEmpty (tag, FilePath) -> m a) ->
m a
withSystemTempDirectories = go mempty
where
go acc (tag :| []) f =
withSystemTempDirectory "withSystemTempDirectories" $ \dir ->
f $ NE.reverse $ (tag, dir) :| acc
go acc (tag :| (t : ts)) f =
withSystemTempDirectory "withSystemTempDirectories" $ \dir ->
go ((tag, dir) : acc) (t :| ts) f
allFiles :: [((), FilePattern)]
allFiles = [((), "*")]
ignoreNone :: [a]
ignoreNone = []