diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs index e1bff42a..63ab5845 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs @@ -1,10 +1,12 @@ using System; using System.Collections.ObjectModel; using System.ComponentModel; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; @@ -275,12 +277,7 @@ private string FullPath return; } - string fullPath = _fileSystem.Path.GetFullPath(value); - - if (!fullPath.EndsWith(_fileSystem.Path.DirectorySeparatorChar)) - { - fullPath += _fileSystem.Path.DirectorySeparatorChar; - } + string fullPath = GetNormalizedFullPath(value); _fullPath = fullPath; } @@ -403,20 +400,27 @@ private bool MatchesFilter(ChangeDescription changeDescription) } private bool MatchesWatcherPath(string? path) + { + return MatchesWatcherPath(path, IncludeSubdirectories); + } + + private bool MatchesWatcherPath(string? path, bool includeSubdirectories) { if (path == null) { return false; } - string fullPath = _fileSystem.Execute.Path.GetFullPath(Path); - if (IncludeSubdirectories) + string fullPath = _fileSystem.Execute.Path.GetFullPath(path); + + if (includeSubdirectories) { - return path.StartsWith(fullPath, _fileSystem.Execute.StringComparisonMode); + return fullPath.StartsWith(FullPath, _fileSystem.Execute.StringComparisonMode); } - return string.Equals(_fileSystem.Execute.Path.GetDirectoryName(path), fullPath, - _fileSystem.Execute.StringComparisonMode); + return string.Equals( + GetNormalizedParent(path), FullPath, _fileSystem.Execute.StringComparisonMode + ); } private void NotifyChange(ChangeDescription item) @@ -532,39 +536,270 @@ private void Stop() private void TriggerRenameNotification(ChangeDescription item) { + // Outside: Outside the FullPath + // Inside: FullPath/ + // Nested: FullPath/*/ + // Deep Nested: FullPath/*/**/ + + bool comesFromOutside = !MatchesWatcherPath(item.OldPath, true); + bool goesToInside = MatchesWatcherPath(item.Path, false); + + // Outside -> Inside + if (comesFromOutside && goesToInside) + { + Created?.Invoke(this, ToFileSystemEventArgs(WatcherChangeTypes.Created, item.Path)); + + return; + } + + bool comesFromInside = MatchesWatcherPath(item.OldPath, false); + bool goesToOutside = !MatchesWatcherPath(item.Path, true); + + // ... -> Outside + if (goesToOutside && (comesFromInside || IncludeSubdirectories)) + { + Deleted?.Invoke(this, ToFileSystemEventArgs(WatcherChangeTypes.Deleted, item.OldPath!)); + + return; + } + + // Inside -> Inside + if (comesFromInside && goesToInside) + { + if (TryMakeRenamedEventArgs(item, out RenamedEventArgs? eventArgs)) + { + Renamed?.Invoke(this, eventArgs); + } + + return; + } + + RenamedContext context = new( + comesFromOutside, comesFromInside, goesToInside, goesToOutside, + GetSubDirectoryCount(item.OldPath!) + ); + if (_fileSystem.Execute.IsWindows) { - if (TryMakeRenamedEventArgs(item, - out RenamedEventArgs? eventArgs)) + TriggerWindowsRenameNotification(item, context); + } + else if (_fileSystem.Execute.IsLinux) + { + TriggerLinuxRenameNotification(item, context); + } + else if (_fileSystem.Execute.IsMac) + { + TriggerMacRenameNotification(item, context); + } + else + { + if (TryMakeRenamedEventArgs(item, out RenamedEventArgs? eventArgs)) { Renamed?.Invoke(this, eventArgs); } - else if (item.OldPath != null) + } + } + + private void TriggerWindowsRenameNotification(ChangeDescription item, RenamedContext context) + { + CheckRenamePremise(context); + + if (context.ComesFromOutside) + { + if (IncludeSubdirectories) { - if (MatchesWatcherPath(item.OldPath)) - { - Deleted?.Invoke(this, ToFileSystemEventArgs( - WatcherChangeTypes.Deleted, item.OldPath)); - } + FireCreated(); + } + } + else if (context.ComesFromInside) + { + FireDeleted(); - if (MatchesWatcherPath(item.Path)) + if (IncludeSubdirectories) + { + FireCreated(); + } + } + else if (context.ComesFromNested || context.ComesFromDeepNested) + { + if (context.GoesToInside) + { + if (IncludeSubdirectories) { - Created?.Invoke(this, ToFileSystemEventArgs( - WatcherChangeTypes.Created, item.Path)); + FireDeleted(); } + + FireCreated(); + } + else if (IsItemNameChange(item) && IncludeSubdirectories) + { + FireRenamed(); + } + else if (IncludeSubdirectories) + { + FireDeleted(); + FireCreated(); } } - else + + return; + + void FireCreated() + { + Created?.Invoke(this, ToFileSystemEventArgs(WatcherChangeTypes.Created, item.Path)); + } + + void FireDeleted() + { + Deleted?.Invoke(this, ToFileSystemEventArgs(WatcherChangeTypes.Deleted, item.OldPath!)); + } + + void FireRenamed() { - TryMakeRenamedEventArgs(item, - out RenamedEventArgs? eventArgs); - if (eventArgs != null) + if (TryMakeRenamedEventArgs(item, out RenamedEventArgs? eventArgs)) { Renamed?.Invoke(this, eventArgs); } } } + private void TriggerMacRenameNotification(ChangeDescription item, RenamedContext context) + { + CheckRenamePremise(context); + + if (context.ComesFromInside && TryMakeRenamedEventArgs(item, out RenamedEventArgs? eventArgs)) + { + Renamed?.Invoke(this, eventArgs); + return; + } + + TriggerLinuxRenameNotification(item, context); + } + + private void TriggerLinuxRenameNotification(ChangeDescription item, RenamedContext context) + { + CheckRenamePremise(context); + + bool hasRenameArgs = TryMakeRenamedEventArgs(item, out RenamedEventArgs? eventArgs); + + if (context.ComesFromOutside) + { + if (IncludeSubdirectories) + { + Created?.Invoke(this, ToFileSystemEventArgs(WatcherChangeTypes.Created, item.Path)); + } + } + else if (context.ComesFromInside) + { + if (IncludeSubdirectories && hasRenameArgs) + { + Renamed?.Invoke(this, eventArgs!); + } + else + { + Deleted?.Invoke( + this, ToFileSystemEventArgs(WatcherChangeTypes.Deleted, item.OldPath!) + ); + } + } + else if (context.GoesToInside) + { + if (IncludeSubdirectories && hasRenameArgs) + { + Renamed?.Invoke(this, eventArgs!); + } + else + { + Created?.Invoke(this, ToFileSystemEventArgs(WatcherChangeTypes.Created, item.Path)); + } + } + else if (IncludeSubdirectories && hasRenameArgs) + { + Renamed?.Invoke(this, eventArgs!); + } + } + + private static void CheckRenamePremise(RenamedContext context) + { + Debug.Assert( + context is not { ComesFromOutside: true, GoesToInside: true }, + "The premise { ComesFromOutside: true, GoesToInside: true } should have been handled." + ); + + Debug.Assert( + context is not { ComesFromInside: true, GoesToInside: true }, + "The premise { ComesFromInside: true, GoesToInside: true } should have been handled." + ); + + Debug.Assert( + !context.GoesToOutside, "The premise { GoesToOutside: true } should have been handled." + ); + } + + private string? GetNormalizedParent(string? path) + { + if (path == null) + { + return null; + } + + string normalized = GetNormalizedFullPath(path); + + return _fileSystem.Execute.Path.GetDirectoryName(normalized) + ?.TrimEnd(_fileSystem.Execute.Path.DirectorySeparatorChar); + } + + private string GetNormalizedFullPath(string path) + { + string normalized = _fileSystem.Execute.Path.GetFullPath(path); + + return normalized.TrimEnd(_fileSystem.Execute.Path.DirectorySeparatorChar); + } + + /// + /// Counts the number of directory separators inside the relative path to + /// + /// + /// The number of directory separators inside the relative path to + /// Returns -1 if the path is outside the + private int GetSubDirectoryCount(string path) + { + string normalizedPath = GetNormalizedFullPath(path); + + if (!normalizedPath.StartsWith(FullPath, _fileSystem.Execute.StringComparisonMode)) + { + return -1; + } + + return normalizedPath.Substring(FullPath.Length) + .TrimStart(_fileSystem.Execute.Path.DirectorySeparatorChar) + .Count(c => c == _fileSystem.Execute.Path.DirectorySeparatorChar); + } + + private bool IsItemNameChange(ChangeDescription changeDescription) + { + string normalizedPath = GetNormalizedFullPath(changeDescription.Path); + string normalizedOldPath = GetNormalizedFullPath(changeDescription.OldPath!); + + string name = _fileSystem.Execute.Path.GetFileName(normalizedPath); + string oldName = _fileSystem.Execute.Path.GetFileName(normalizedOldPath); + + if (name.Equals(oldName, _fileSystem.Execute.StringComparisonMode)) + { + return false; + } + + if (name.Length == 0 || oldName.Length == 0) + { + return false; + } + + string? parent = _fileSystem.Execute.Path.GetDirectoryName(normalizedPath); + string? oldParent = _fileSystem.Execute.Path.GetDirectoryName(normalizedOldPath); + + return string.Equals(parent, oldParent, _fileSystem.Execute.StringComparisonMode); + } + private bool TryMakeRenamedEventArgs( ChangeDescription changeDescription, [NotNullWhen(true)] out RenamedEventArgs? eventArgs @@ -586,11 +821,7 @@ private bool TryMakeRenamedEventArgs( SetFileSystemEventArgsFullPath(eventArgs, name); SetRenamedEventArgsFullPath(eventArgs, oldName); - return _fileSystem.Execute.Path.GetDirectoryName(changeDescription.Path)?.Equals( - _fileSystem.Execute.Path.GetDirectoryName(changeDescription.OldPath), - _fileSystem.Execute.StringComparisonMode - ) - ?? true; + return true; } private FileSystemEventArgs ToFileSystemEventArgs( @@ -608,7 +839,7 @@ private FileSystemEventArgs ToFileSystemEventArgs( private string TransformPathAndName(string changeDescriptionPath) { - return changeDescriptionPath.Substring(FullPath.Length).TrimStart(_fileSystem.Path.DirectorySeparatorChar); + return changeDescriptionPath.Substring(FullPath.Length).TrimStart(_fileSystem.Execute.Path.DirectorySeparatorChar); } private void SetFileSystemEventArgsFullPath(FileSystemEventArgs args, string name) @@ -618,7 +849,7 @@ private void SetFileSystemEventArgsFullPath(FileSystemEventArgs args, string nam return; } - string fullPath = _fileSystem.Path.Combine(Path, name); + string fullPath = _fileSystem.Execute.Path.Combine(Path, name); // FileSystemEventArgs implicitly combines the path in https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemEventArgs.cs // HACK: The combination uses the system separator, so to simulate the behavior, we must override it using reflection! @@ -640,7 +871,7 @@ private void SetRenamedEventArgsFullPath(RenamedEventArgs args, string oldName) return; } - string fullPath = _fileSystem.Path.Combine(Path, oldName); + string fullPath = _fileSystem.Execute.Path.Combine(Path, oldName); // FileSystemEventArgs implicitly combines the path in https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemEventArgs.cs // HACK: The combination uses the system separator, so to simulate the behavior, we must override it using reflection! @@ -736,6 +967,36 @@ public WaitForChangedResultMock( public bool TimedOut { get; } } + [StructLayout(LayoutKind.Auto)] + private readonly struct RenamedContext( + bool comesFromOutside, + bool comesFromInside, + bool goesToInside, + bool goesToOutside, + int oldSubDirectoryCount + ) + { + private const int NestedLevelCount = 1; + + public bool ComesFromOutside { get; } = comesFromOutside; + + public bool ComesFromInside { get; } = comesFromInside; + + public bool GoesToInside { get; } = goesToInside; + + public bool GoesToOutside { get; } = goesToOutside; + + /// + /// If this is then is + /// + public bool ComesFromNested { get; } = oldSubDirectoryCount == NestedLevelCount; + + /// + /// If this is then is + /// + public bool ComesFromDeepNested { get; } = oldSubDirectoryCount > NestedLevelCount; + } + internal sealed class ChangeDescriptionEventArgs(ChangeDescription changeDescription) : EventArgs { diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs new file mode 100644 index 00000000..ccdf9162 --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs @@ -0,0 +1,401 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Threading; + +namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; + +public partial class MoveTests +{ + [Theory] + [InlineData(true, "nested")] + [InlineData(false, "nested")] + [InlineData(true, "nested", "deep")] + [InlineData(false, "nested", "deep")] + public async Task Unix_MoveOutsideToNested_ShouldInvokeNothingOrCreated( + bool includeSubdirectories, + params string[] paths + ) + { + Skip.If(Test.RunsOnWindows); + + // Arrange + + // short names, otherwise the path will be too long + const string outsideDirectory = "outside"; + const string insideDirectory = "inside"; + const string targetName = "target"; + + string nestedDirectory = FileSystem.Path.Combine(paths); + + string expectedName = FileSystem.Path.Combine(nestedDirectory, targetName); + + string insideSubDirectory = FileSystem.Path.Combine(insideDirectory, nestedDirectory); + + string outsideTarget = FileSystem.Path.Combine(outsideDirectory, targetName); + string insideTarget = FileSystem.Path.Combine(insideSubDirectory, targetName); + + FileSystem.Initialize().WithSubdirectories(insideSubDirectory, outsideTarget); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted + ); + + using ManualResetEventSlim changedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Changed + ); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.Directory.Move(outsideTarget, insideTarget); + + // Assert + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsSingleOrEmpty(createdBag, !includeSubdirectories); + + if (includeSubdirectories) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Created)) + .And + .For(x => x.Name, it => it.IsEqualTo(expectedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(insideTarget)); + } + } + + [Theory] + [InlineData(true, "nested")] + [InlineData(false, "nested")] + [InlineData(true, "nested", "deep")] + [InlineData(false, "nested", "deep")] + public async Task Unix_MoveInsideToNested_ShouldInvokeDeletedOrRenamed( + bool includeSubdirectories, + params string[] paths + ) + { + Skip.If(Test.RunsOnWindows); + SkipIfBrittleTestsShouldBeSkipped(Test.RunsOnMac); + + // Arrange + + // When moving items from inside to nested on Mac when IncludeSubdirectories is false, then it will invoke a Renamed rather than Deleted + bool isRenamed = Test.RunsOnMac || includeSubdirectories; + + // short names, otherwise the path will be too long + const string insideDirectory = "inside"; + const string targetName = "target"; + + string nestedDirectory = FileSystem.Path.Combine(paths); + + string expectedName = FileSystem.Path.Combine(nestedDirectory, targetName); + + string insideSubDirectory = FileSystem.Path.Combine(insideDirectory, nestedDirectory); + + string insideTarget = FileSystem.Path.Combine(insideDirectory, targetName); + string nestedTarget = FileSystem.Path.Combine(insideSubDirectory, targetName); + + FileSystem.Initialize().WithSubdirectories(insideSubDirectory, insideTarget); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted, + out ConcurrentBag deletedBag + ); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler( + fileSystemWatcher, out ConcurrentBag renamedBag + ); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created + ); + + using ManualResetEventSlim changedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Changed + ); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.Directory.Move(insideTarget, nestedTarget); + + // Assert + + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(!isRenamed); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(isRenamed); + + await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsSingleOrEmpty(deletedBag, isRenamed); + await ThatIsSingleOrEmpty(renamedBag, !isRenamed); + + if (isRenamed) + { + await That(renamedBag.TryTake(out RenamedEventArgs? renamedEvent)).IsTrue(); + + await That(renamedEvent!) + .For(x => x.Name, it => it.IsEqualTo(expectedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(nestedTarget)) + .And + .For(x => x.OldName, it => it.IsEqualTo(targetName)) + .And + .For(x => x.OldFullPath, it => it.IsEqualTo(insideTarget)); + } + else + { + await That(deletedBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Deleted)) + .And + .For(x => x.Name, it => it.IsEqualTo(targetName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(insideTarget)); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(true, "deep")] + [InlineData(false, "deep")] + public async Task Unix_MoveNestedTo_ShouldInvokeCreatedOrRenamed( + bool includeSubdirectories, + string? path = null + ) + { + Skip.If(Test.RunsOnWindows); + SkipIfBrittleTestsShouldBeSkipped(Test.RunsOnMac); + + // Arrange + + bool isCreated = !includeSubdirectories && path is null; + + // short names, otherwise the path will be too long + const string insideDirectory = "inside"; + const string targetName = "target"; + const string nestedDirName = "nested"; + + string expectedOldName = FileSystem.Path.Combine(nestedDirName, targetName); + + string expectedName = path is null + ? targetName + : FileSystem.Path.Combine(nestedDirName, path, targetName); + + string nestedDirectory = FileSystem.Path.Combine(insideDirectory, nestedDirName); + + string targetDir = path is null + ? insideDirectory + : FileSystem.Path.Combine(nestedDirectory, path); + + string target = FileSystem.Path.Combine(targetDir, targetName); + + string source = FileSystem.Path.Combine(nestedDirectory, targetName); + + FileSystem.Initialize().WithSubdirectories(targetDir, source); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler( + fileSystemWatcher, out ConcurrentBag renamedBag + ); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag + ); + + using ManualResetEventSlim changedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Changed + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted + ); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.Directory.Move(source, target); + + // Assert + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(isCreated); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsSingleOrEmpty(createdBag, !isCreated); + + if (isCreated) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Created)) + .And + .For(x => x.Name, it => it.IsEqualTo(expectedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(target)); + } + + await ThatIsSingleOrEmpty(renamedBag, !includeSubdirectories); + + if (includeSubdirectories) + { + await That(renamedBag.TryTake(out RenamedEventArgs? renamedEvent)).IsTrue(); + + await That(renamedEvent!) + .For(x => x.Name, it => it.IsEqualTo(expectedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(target)) + .And + .For(x => x.OldName, it => it.IsEqualTo(expectedOldName)) + .And + .For(x => x.OldFullPath, it => it.IsEqualTo(source)); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(true, "nested")] + [InlineData(false, "nested")] + public async Task Unix_MoveDeepNestedTo_ShouldInvokeRenamedOrCreated( + bool includeSubdirectories, + string? path = null + ) + { + Skip.If(Test.RunsOnWindows); + SkipIfBrittleTestsShouldBeSkipped(Test.RunsOnMac); + + // Arrange + + bool isCreated = !includeSubdirectories && path is null; + + // short names, otherwise the path will be too long + const string insideDirectory = "inside"; + const string targetName = "target"; + + string deepNestedDirectory = FileSystem.Path.Combine("nested", "deep"); + + string expectedOldName = FileSystem.Path.Combine(deepNestedDirectory, targetName); + string expectedName = path is null ? targetName : FileSystem.Path.Combine(path, targetName); + + string targetDir = path is null + ? insideDirectory + : FileSystem.Path.Combine(insideDirectory, path); + + string target = FileSystem.Path.Combine(targetDir, targetName); + + string source = FileSystem.Path.Combine(insideDirectory, deepNestedDirectory, targetName); + + FileSystem.Initialize().WithSubdirectories(targetDir, source); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler( + fileSystemWatcher, out ConcurrentBag renamedBag + ); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag + ); + + using ManualResetEventSlim changedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Changed + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted + ); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.Directory.Move(source, target); + + // Assert + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(isCreated); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsSingleOrEmpty(createdBag, !isCreated); + await ThatIsSingleOrEmpty(renamedBag, !includeSubdirectories); + + if (isCreated) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Created)) + .And + .For(x => x.Name, it => it.IsEqualTo(expectedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(target)); + } + + if (includeSubdirectories) + { + await That(renamedBag.TryTake(out RenamedEventArgs? renamedEvent)).IsTrue(); + + await That(renamedEvent!) + .For(x => x.Name, it => it.IsEqualTo(expectedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(target)) + .And + .For(x => x.OldName, it => it.IsEqualTo(expectedOldName)) + .And + .For(x => x.OldFullPath, it => it.IsEqualTo(source)); + } + } +} diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs new file mode 100644 index 00000000..50d3203b --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs @@ -0,0 +1,371 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Threading; + +namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; + +public partial class MoveTests +{ + [Theory] + [InlineData(true, "nested")] + [InlineData(false, "nested")] + [InlineData(true, "nested", "deep")] + [InlineData(false, "nested", "deep")] + public async Task Windows_MoveOutsideToNested_ShouldInvokeCreatedAndChanged( + bool includeSubdirectories, + params string[] paths + ) + { + Skip.IfNot(Test.RunsOnWindows); + + // Arrange + + // short names, otherwise the path will be too long + const string outsideDirectory = "outside"; + const string insideDirectory = "inside"; + const string targetName = "target"; + + string nestedDirectory = FileSystem.Path.Combine(paths); + + string expectedName = FileSystem.Path.Combine(nestedDirectory, targetName); + + string insideSubDirectory = FileSystem.Path.Combine(insideDirectory, nestedDirectory); + + string outsideTarget = FileSystem.Path.Combine(outsideDirectory, targetName); + string insideTarget = FileSystem.Path.Combine(insideSubDirectory, targetName); + + FileSystem.Initialize().WithSubdirectories(insideSubDirectory, outsideTarget); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted + ); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.Directory.Move(outsideTarget, insideTarget); + + // Assert + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsSingleOrEmpty(createdBag, !includeSubdirectories); + + if (includeSubdirectories) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Created)) + .And + .For(x => x.Name, it => it.IsEqualTo(expectedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(insideTarget)); + } + } + + [Theory] + [InlineData(true, "nested")] + [InlineData(false, "nested")] + [InlineData(true, "nested", "deep")] + [InlineData(false, "nested", "deep")] + public async Task Windows_MoveInsideToNested_ShouldInvokeDeletedCreatedAndChanged( + bool includeSubdirectories, + params string[] paths + ) + { + Skip.IfNot(Test.RunsOnWindows); + + // Arrange + + // short names, otherwise the path will be too long + const string insideDirectory = "inside"; + const string targetName = "target"; + + string nestedDirectory = FileSystem.Path.Combine(paths); + + string expectedCreatedName = FileSystem.Path.Combine(nestedDirectory, targetName); + + string insideSubDirectory = FileSystem.Path.Combine(insideDirectory, nestedDirectory); + + string insideTarget = FileSystem.Path.Combine(insideDirectory, targetName); + string nestedTarget = FileSystem.Path.Combine(insideSubDirectory, targetName); + + FileSystem.Initialize().WithSubdirectories(insideSubDirectory, insideTarget); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted, + out ConcurrentBag deletedBag + ); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag + ); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.Directory.Move(insideTarget, nestedTarget); + + // Assert + + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await That(deletedBag).HasSingle(); + + await That(deletedBag.TryTake(out FileSystemEventArgs? deletedEvent)).IsTrue(); + + await That(deletedEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Deleted)) + .And + .For(x => x.Name, it => it.IsEqualTo(targetName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(insideTarget)); + + await ThatIsSingleOrEmpty(createdBag, !includeSubdirectories); + + if (includeSubdirectories) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Created)) + .And + .For(x => x.Name, it => it.IsEqualTo(expectedCreatedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(nestedTarget)); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(true, "deep")] + [InlineData(false, "deep")] + public async Task Windows_MoveNestedTo_ShouldInvokeDeletedCreatedAndChanged( + bool includeSubdirectories, + string? path = null + ) + { + Skip.IfNot(Test.RunsOnWindows); + + // Arrange + + bool isCreated = path is null || includeSubdirectories; + + // short names, otherwise the path will be too long + const string insideDirectory = "inside"; + const string targetName = "target"; + const string nestedDirectoryName = "nested"; + + string expectedDeletedName = FileSystem.Path.Combine(nestedDirectoryName, targetName); + + string expectedCreatedName = path is null + ? targetName + : FileSystem.Path.Combine(nestedDirectoryName, path, targetName); + + string nestedDirectory = FileSystem.Path.Combine(insideDirectory, nestedDirectoryName); + + string targetDir = path is null + ? insideDirectory + : FileSystem.Path.Combine(nestedDirectory, path); + + string target = FileSystem.Path.Combine(targetDir, targetName); + + string source = FileSystem.Path.Combine(nestedDirectory, targetName); + + FileSystem.Initialize().WithSubdirectories(targetDir, source); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted, + out ConcurrentBag deletedBag + ); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.Directory.Move(source, target); + + // Assert + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(isCreated); + + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsSingleOrEmpty(createdBag, !isCreated); + await ThatIsSingleOrEmpty(deletedBag, !includeSubdirectories); + + if (isCreated) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Created)) + .And + .For(x => x.Name, it => it.IsEqualTo(expectedCreatedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(target)); + } + + if (includeSubdirectories) + { + await That(deletedBag.TryTake(out FileSystemEventArgs? deletedEvent)).IsTrue(); + + await That(deletedEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Deleted)) + .And + .For(x => x.Name, it => it.IsEqualTo(expectedDeletedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(source)); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(true, "nested")] + [InlineData(false, "nested")] + public async Task Windows_MoveDeepNestedTo_ShouldInvokeDeletedCreatedAndChanged( + bool includeSubdirectories, + string? path = null + ) + { + Skip.IfNot(Test.RunsOnWindows); + + // Arrange + + bool isCreated = path is null || includeSubdirectories; + + // short names, otherwise the path will be too long + const string insideDirectory = "inside"; + const string targetName = "target"; + const string nestedDirectoryName = "nested"; + const string deepNestedDirectoryName = "deep"; + + string expectedDeletedName = FileSystem.Path.Combine( + nestedDirectoryName, deepNestedDirectoryName, targetName + ); + + string expectedCreatedName = path is null + ? targetName + : FileSystem.Path.Combine(path, targetName); + + string deepNestedDirectory = FileSystem.Path.Combine( + insideDirectory, nestedDirectoryName, deepNestedDirectoryName + ); + + string targetDir = path is null + ? insideDirectory + : FileSystem.Path.Combine(insideDirectory, path); + + string target = FileSystem.Path.Combine(targetDir, targetName); + + string source = FileSystem.Path.Combine(deepNestedDirectory, targetName); + + FileSystem.Initialize().WithSubdirectories(targetDir, source); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted, + out ConcurrentBag deletedBag + ); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.Directory.Move(source, target); + + // Assert + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(isCreated); + + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsSingleOrEmpty(createdBag, !isCreated); + await ThatIsSingleOrEmpty(deletedBag, !includeSubdirectories); + + if (isCreated) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Created)) + .And + .For(x => x.Name, it => it.IsEqualTo(expectedCreatedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(target)); + } + + if (includeSubdirectories) + { + await That(deletedBag.TryTake(out FileSystemEventArgs? deletedEvent)).IsTrue(); + + await That(deletedEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Deleted)) + .And + .For(x => x.Name, it => it.IsEqualTo(expectedDeletedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(source)); + } + } +} diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs new file mode 100644 index 00000000..15992e86 --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs @@ -0,0 +1,328 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Threading; + +namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; + +[FileSystemTests] +public partial class MoveTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task MoveOutsideToInside_ShouldInvokeCreated(bool includeSubdirectories) + { + // Arrange + + // short names, otherwise the path will be too long + const string outsideDirectory = "outside"; + const string insideDirectory = "inside"; + const string targetName = "target"; + string outsideTarget = FileSystem.Path.Combine(outsideDirectory, targetName); + string insideTarget = FileSystem.Path.Combine(insideDirectory, targetName); + + FileSystem.Initialize().WithSubdirectories( + outsideDirectory, insideDirectory, outsideTarget + ); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted + ); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.Directory.Move(outsideTarget, insideTarget); + + // Assert + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await That(createdBag).HasSingle().Which + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Created)) + .And + .For(x => x.Name, it => it.IsEqualTo(targetName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(insideTarget)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(true, "nested")] + [InlineData(false, "nested")] + [InlineData(true, "nested", "deep")] + [InlineData(false, "nested", "deep")] + public async Task MoveToOutside_ShouldInvokeDeleted( + bool includeSubdirectories, + params string[] paths + ) + { + SkipIfBrittleTestsShouldBeSkipped(Test.RunsOnMac); + + // Arrange + + bool shouldInvokeDeleted = includeSubdirectories || paths.Length == 0; + + // short names, otherwise the path will be too long + const string outsideDirectory = "outside"; + const string insideDirectory = "inside"; + const string targetName = "target"; + + string insideSubDirectory = FileSystem.Path.Combine( + insideDirectory, FileSystem.Path.Combine(paths) + ); + + string outsideTarget = FileSystem.Path.Combine(outsideDirectory, targetName); + string insideTarget = FileSystem.Path.Combine(insideSubDirectory, targetName); + + string expectedDeletedName + = FileSystem.Path.Combine(FileSystem.Path.Combine(paths), targetName); + + FileSystem.Initialize().WithSubdirectories(outsideDirectory, insideDirectory, insideTarget); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted, + out ConcurrentBag deletedBag + ); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created + ); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.Directory.Move(insideTarget, outsideTarget); + + // Assert + + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(shouldInvokeDeleted); + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsSingleOrEmpty(deletedBag, !shouldInvokeDeleted); + + if (shouldInvokeDeleted) + { + await That(deletedBag.TryTake(out FileSystemEventArgs? deletedEvent)).IsTrue(); + + await That(deletedEvent!) + .For(x => x.ChangeType, it => it.IsEqualTo(WatcherChangeTypes.Deleted)) + .And + .For(x => x.Name, it => it.IsEqualTo(expectedDeletedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(insideTarget)); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(true, "nested")] + [InlineData(false, "nested")] + [InlineData(true, "nested", "deep")] + [InlineData(false, "nested", "deep")] + public async Task MoveToSameDirectory_ShouldInvokeRenamed( + bool includeSubdirectories, + params string[] paths + ) + { + SkipIfBrittleTestsShouldBeSkipped(Test.RunsOnMac); + + // Arrange + + bool shouldInvokeRenamed = includeSubdirectories || paths.Length == 0; + + // short names, otherwise the path will be too long + const string insideDirectory = "inside"; + const string targetName = "target"; + const string targetName2 = "target2"; + + string insideSubDirectory = FileSystem.Path.Combine( + insideDirectory, FileSystem.Path.Combine(paths) + ); + + string insideTarget = FileSystem.Path.Combine(insideSubDirectory, targetName); + string insideTarget2 = FileSystem.Path.Combine(insideSubDirectory, targetName2); + + string expectedName = FileSystem.Path.Combine(FileSystem.Path.Combine(paths), targetName2); + + string expectedOldName = FileSystem.Path.Combine( + FileSystem.Path.Combine(paths), targetName + ); + + FileSystem.Initialize().WithSubdirectories(insideDirectory, insideTarget); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler( + fileSystemWatcher, out ConcurrentBag renamedBag + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted + ); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created + ); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.Directory.Move(insideTarget, insideTarget2); + + // Assert + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(shouldInvokeRenamed); + + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsSingleOrEmpty(renamedBag, !shouldInvokeRenamed); + + if (shouldInvokeRenamed) + { + await That(renamedBag.TryTake(out RenamedEventArgs? renamedEvent)).IsTrue(); + + await That(renamedEvent!) + .For(x => x.OldName, it => it.IsEqualTo(expectedOldName)) + .And + .For(x => x.Name, it => it.IsEqualTo(expectedName)) + .And + .For(x => x.FullPath, it => it.IsEqualTo(insideTarget2)) + .And + .For(x => x.OldFullPath, it => it.IsEqualTo(insideTarget)); + } + } + + private static async Task ThatIsSingleOrEmpty(IEnumerable value, bool expectEmpty) + where T : class + { + if (expectEmpty) + { + await That(value).IsEmpty(); + } + else + { + await That(value).HasSingle(); + } + } + + private static ManualResetEventSlim AddEventHandler( + IFileSystemWatcher fileSystemWatcher, + WatcherChangeTypes changeType + ) + { + return AddEventHandler(fileSystemWatcher, changeType, out _); + } + + private static ManualResetEventSlim AddRenamedEventHandler(IFileSystemWatcher fileSystemWatcher) + { + return AddRenamedEventHandler(fileSystemWatcher, out _); + } + + private static ManualResetEventSlim AddEventHandler( + IFileSystemWatcher fileSystemWatcher, + WatcherChangeTypes changeType, + out ConcurrentBag events + ) + { + ManualResetEventSlim ms = new(); + ConcurrentBag eventBag = new(); + + events = eventBag; + + FileSystemEventHandler handler = (_, args) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + eventBag.Add(args); + ms.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + switch (changeType) + { + case WatcherChangeTypes.Created: + fileSystemWatcher.Created += handler; + + break; + case WatcherChangeTypes.Changed: + fileSystemWatcher.Changed += handler; + + break; + case WatcherChangeTypes.Deleted: + fileSystemWatcher.Deleted += handler; + + break; + default: + throw new ArgumentOutOfRangeException(nameof(changeType), changeType, null); + } + + return ms; + } + + private static ManualResetEventSlim AddRenamedEventHandler( + IFileSystemWatcher fileSystemWatcher, + out ConcurrentBag events + ) + { + ManualResetEventSlim ms = new(); + ConcurrentBag eventBag = new(); + events = eventBag; + + fileSystemWatcher.Renamed += (_, args) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + eventBag.Add(args); + ms.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + return ms; + } +}