atelier-core-0.6.0.0: test/Unit/Atelier/Effects/FileWatcherSpec.hs
module Unit.Atelier.Effects.FileWatcherSpec (spec_FileWatcher) where
import Control.Concurrent (forkIO, killThread, newQSem, signalQSem, waitQSem)
import Data.IORef (modifyIORef, newIORef, readIORef)
import Data.List (isSuffixOf)
import Effectful (IOE, runEff)
import Effectful.Concurrent (Concurrent, runConcurrent)
import Hedgehog (Gen, PropertyT, forAll, (===))
import Test.Hspec (Spec, describe, it, shouldBe)
import Test.Hspec.Hedgehog (hedgehog)
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range
import Atelier.Effects.FileWatcher
( FileEvent (..)
, FileWatcher
, Watch
, deduplicateDirs
, dir
, dirWhere
, matchesAny
, runFileWatcherScripted
, watchFilePaths
)
spec_FileWatcher :: Spec
spec_FileWatcher = do
describe "deduplicateDirs" do
describe "properties" do
it "result is an antichain: no element is an ancestor of another"
$ hedgehog propAntichain
it "result covers all inputs: every input has an ancestor-or-equal in the result"
$ hedgehog propCoverage
it "is idempotent"
$ hedgehog propIdempotent
it "result is a subset of the input"
$ hedgehog propSubset
describe "edge cases" do
it "returns empty list unchanged" do
deduplicateDirs [] `shouldBe` []
it "does not treat a dir as an ancestor of a similarly named dir" do
deduplicateDirs ["/src", "/srcover"] `shouldBe` ["/src", "/srcover"]
describe "matchesAny" do
it "matches a file under a watched directory" do
matchesAny [dir "/proj/src"] "/proj/src/Foo.hs"
`shouldBe` True
it "does not match a relative watch dir against an absolute event path" do
-- runFileWatcherIO must canonicalize Watch paths to absolute before
-- calling matchesAny, because fsnotify always reports absolute paths.
matchesAny [dir "src"] "/proj/src/Foo.hs"
`shouldBe` False
it "applies the file predicate" do
matchesAny [dirWhere "/proj/src" (\f -> ".hs" `isSuffixOf` f)] "/proj/src/Foo.hs"
`shouldBe` True
matchesAny [dirWhere "/proj/src" (\f -> ".hs" `isSuffixOf` f)] "/proj/src/Foo.js"
`shouldBe` False
describe "runFileWatcherScripted" testScripted
--------------------------------------------------------------------------------
-- Scripted interpreter tests
--------------------------------------------------------------------------------
testScripted :: Spec
testScripted = do
describe "watchFilePaths" do
it "calls the callback with the scripted path" do
paths <- collectPaths ["/src/Foo.hs"]
paths `shouldBe` ["/src/Foo.hs"]
it "calls the callback with each path in order" do
paths <- collectPaths ["/src/Foo.hs", "/src/Bar.hs"]
paths `shouldBe` ["/src/Foo.hs", "/src/Bar.hs"]
it "ignores the watch specification" do
paths <- collectPathsWith [dir "/any"] ["/src/Foo.hs"]
paths `shouldBe` ["/src/Foo.hs"]
it "passes the full path to the callback unchanged" do
let path = "/home/user/project/src/Some/Deep/Module.hs"
paths <- collectPaths [path]
paths `shouldBe` [path]
--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------
-- | Run the scripted interpreter, collecting all callback-delivered paths.
-- Uses a semaphore to wait for exactly N events before cancelling the watcher.
collectPaths :: [FilePath] -> IO [FilePath]
collectPaths = collectPathsWith []
collectPathsWith :: [Watch] -> [FilePath] -> IO [FilePath]
collectPathsWith watches scripted = do
ref <- newIORef []
sem <- newQSem 0
tid <- forkIO $ void $ runScripted scripted $ watchFilePaths watches \filePath _fileEvent -> liftIO do
modifyIORef ref (<> [filePath])
signalQSem sem
replicateM_ (length scripted) (waitQSem sem)
killThread tid
readIORef ref
runScripted :: [FilePath] -> Eff '[FileWatcher, Concurrent, IOE] a -> IO a
runScripted paths = runEff . runConcurrent . runFileWatcherScripted (map (,Modified) paths)
--------------------------------------------------------------------------------
-- Properties
--------------------------------------------------------------------------------
propAntichain :: PropertyT IO ()
propAntichain = do
dirs <- forAll genDirs
let result = deduplicateDirs dirs
let pairs = [(a, b) | a <- result, b <- result, a /= b]
all (\(a, b) -> not (isStrictAncestor a b)) pairs === True
propCoverage :: PropertyT IO ()
propCoverage = do
dirs <- forAll genDirs
let result = deduplicateDirs dirs
all (isCoveredBy result) dirs === True
propIdempotent :: PropertyT IO ()
propIdempotent = do
dirs <- forAll genDirs
deduplicateDirs (deduplicateDirs dirs) === deduplicateDirs dirs
propSubset :: PropertyT IO ()
propSubset = do
dirs <- forAll genDirs
let result = deduplicateDirs dirs
all (`elem` dirs) result === True
--------------------------------------------------------------------------------
-- Generators
--------------------------------------------------------------------------------
genDirs :: Gen [FilePath]
genDirs = Gen.list (Range.linear 0 10) genAbsDir
-- Generates absolute paths like /a/b/c using short segments to encourage
-- overlaps between generated paths.
genAbsDir :: Gen FilePath
genAbsDir = do
segments <- Gen.list (Range.linear 1 4) genSegment
pure $ "/" <> intercalate "/" segments
genSegment :: Gen String
genSegment = Gen.string (Range.linear 1 3) (Gen.element ['a', 'b', 'c', 'd'])
--------------------------------------------------------------------------------
-- Helpers (mirror of FileWatcher internals)
--------------------------------------------------------------------------------
isStrictAncestor :: FilePath -> FilePath -> Bool
isStrictAncestor parent child = (parent <> "/") `isPrefixOf` child
isCoveredBy :: [FilePath] -> FilePath -> Bool
isCoveredBy result d = any (\r -> r == d || isStrictAncestor r d) result