atelier-core-0.1.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