From 37a536a2f06546b9c7234cff5da9a210d3c71bd3 Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Thu, 15 Jan 2026 12:58:11 +0100 Subject: [PATCH 01/11] test: added tests for watching moving files --- .../FileSystemWatcher/MoveTests.Unix.cs | 270 ++++++++++++++++++ .../FileSystemWatcher/MoveTests.Windows.cs | 257 +++++++++++++++++ .../FileSystem/FileSystemWatcher/MoveTests.cs | 175 ++++++++++++ 3 files changed, 702 insertions(+) create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs 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..96167e3a --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs @@ -0,0 +1,270 @@ +using System.IO; +using System.Threading; + +namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; + +public partial class MoveTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Linux_ParentWatcher_MoveToChild_ShouldInvokeEitherDeletedOrRenamed( + bool includeSubdirectories + ) + { + Skip.IfNot(Test.RunsOnLinux); + + // short names, otherwise the path will be too long + const string parentDirectory = "parent"; + const string childDirectory = "child"; + + FileSystem.Initialize().WithSubdirectories(parentDirectory, childDirectory); + + string newPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + + using ManualResetEventSlim deletedMs = new(); + FileSystemEventArgs? deletedResult = null; + + using ManualResetEventSlim renamedMs = new(); + FileSystemEventArgs? renamedResult = null; + + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + + fileSystemWatcher.Deleted += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + deletedResult = eventArgs; + deletedMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.Renamed += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + renamedResult = eventArgs; + renamedMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(childDirectory, newPath); + + // wait triple the amount in case the other event is handled first + await That(deletedMs.Wait(ExpectTimeout * 2, TestContext.Current.CancellationToken)) + .IsNotEqualTo(includeSubdirectories); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + if (includeSubdirectories) + { + await That(deletedResult).IsNull(); + await That(renamedResult).IsNotNull(); + } + else + { + await That(deletedResult).IsNotNull(); + await That(renamedResult).IsNull(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task MacOs_ParentWatcher_MoveToChild_ShouldInvokeRenamed( + bool includeSubdirectories + ) + { + Skip.IfNot(Test.RunsOnLinux); + + // short names, otherwise the path will be too long + const string parentDirectory = "parent"; + const string childDirectory = "child"; + + FileSystem.Initialize().WithSubdirectories(parentDirectory, childDirectory); + + string newPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + + using ManualResetEventSlim renamedMs = new(); + FileSystemEventArgs? renamedResult = null; + + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + + fileSystemWatcher.Renamed += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + renamedResult = eventArgs; + renamedMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(childDirectory, newPath); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + if (includeSubdirectories) + { + await That(renamedResult).IsNotNull(); + } + else + { + await That(renamedResult).IsNull(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Unix_ParentWatcher_MoveInPlace_ShouldInvokeNothing(bool includeSubdirectories) + { + Skip.If(Test.RunsOnWindows); + + // short names, otherwise the path will be too long + const string parentDirectory = "parent"; + const string childDirectory = "child"; + + FileSystem.Initialize().WithSubdirectory(parentDirectory) + .Initialized(x => x.WithSubdirectory(childDirectory)); + + string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + string newPath = FileSystem.Path.Combine(parentDirectory, "newChild"); + + using ManualResetEventSlim renamedMs = new(); + FileSystemEventArgs? renamedResult = null; + + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + + fileSystemWatcher.Renamed += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + renamedResult = eventArgs; + renamedMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(oldPath, newPath); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + if (includeSubdirectories) + { + await That(renamedResult).IsNotNull(); + } + else + { + await That(renamedResult).IsNull(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Unix_ParentWatcher_MoveToParent_IncludeSubDirectories_ShouldInvokeCreated( + bool includeSubdirectories + ) + { + Skip.If(Test.RunsOnWindows); + + // short names, otherwise the path will be too long + const string parentDirectory = "parent"; + const string childDirectory = "child"; + + FileSystem.Initialize().WithSubdirectory(parentDirectory) + .Initialized(x => x.WithSubdirectory(childDirectory)); + + string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + + using ManualResetEventSlim renamedMs = new(); + FileSystemEventArgs? renamedResult = null; + + using ManualResetEventSlim createdMs = new(); + FileSystemEventArgs? createdResult = null; + + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + + fileSystemWatcher.Renamed += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + renamedResult = eventArgs; + renamedMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.Created += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + createdResult = eventArgs; + createdMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(oldPath, childDirectory); + + // wait double the amount in case the other event is handled first + await That(createdMs.Wait(ExpectTimeout * 2, TestContext.Current.CancellationToken)) + .IsNotEqualTo(includeSubdirectories); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + if (includeSubdirectories) + { + await That(createdResult).IsNull(); + await That(renamedResult).IsNotNull(); + } + else + { + await That(createdResult).IsNotNull(); + await That(renamedResult).IsNull(); + } + } +} 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..65d571ef --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs @@ -0,0 +1,257 @@ +using System.IO; +using System.Threading; + +namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; + +public partial class MoveTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Windows_ParentWatcher_MoveToChild_ShouldInvokeDeletedAndCreatedAndChanged( + bool includeSubdirectories + ) + { + Skip.IfNot(Test.RunsOnWindows); + + // short names, otherwise the path will be too long + const string parentDirectory = "parent"; + const string childDirectory = "child"; + + FileSystem.Initialize().WithSubdirectories(parentDirectory, childDirectory); + + string newPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + + using ManualResetEventSlim deletedMs = new(); + FileSystemEventArgs? deletedResult = null; + + using ManualResetEventSlim createdMs = new(); + FileSystemEventArgs? createdResult = null; + + using ManualResetEventSlim changedMs = new(); + FileSystemEventArgs? changedResult = null; + + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + + fileSystemWatcher.Deleted += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + deletedResult = eventArgs; + deletedMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.Created += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + createdResult = eventArgs; + createdMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.Changed += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + changedResult = eventArgs; + changedMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(childDirectory, newPath); + + // wait triple the amount in case the other two events are handled first + await That(deletedMs.Wait(ExpectTimeout * 3, TestContext.Current.CancellationToken)) + .IsTrue(); + + await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + await That(deletedResult).IsNotNull(); + await That(changedResult).IsNotNull(); + + if (includeSubdirectories) + { + await That(createdResult).IsNotNull(); + } + else + { + await That(createdResult).IsNull(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Windows_ParentWatcher_MoveInPlace_ShouldInvokeChanged( + bool includeSubdirectories + ) + { + Skip.IfNot(Test.RunsOnWindows); + + // short names, otherwise the path will be too long + const string parentDirectory = "parent"; + const string childDirectory = "child"; + + FileSystem.Initialize().WithSubdirectory(parentDirectory) + .Initialized(x => x.WithSubdirectory(childDirectory)); + + string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + string newPath = FileSystem.Path.Combine(parentDirectory, "newChild"); + + using ManualResetEventSlim renamedMs = new(); + FileSystemEventArgs? renamedResult = null; + + using ManualResetEventSlim changedMs = new(); + FileSystemEventArgs? changedResult = null; + + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + + fileSystemWatcher.Renamed += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + renamedResult = eventArgs; + renamedMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.Changed += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + changedResult = eventArgs; + changedMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(oldPath, newPath); + + // wait double the amount in case the other event is handled first + await That(changedMs.Wait(ExpectTimeout * 2, TestContext.Current.CancellationToken)).IsTrue(); + + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(includeSubdirectories); + + await That(changedResult).IsNotNull(); + + if (includeSubdirectories) + { + await That(renamedResult).IsNotNull(); + } + else + { + await That(renamedResult).IsNull(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Windows_ParentWatcher_MoveToParent_IncludeSubDirectories_ShouldInvokeCreated( + bool includeSubdirectories + ) + { + Skip.IfNot(Test.RunsOnWindows); + + // short names, otherwise the path will be too long + const string parentDirectory = "parent"; + const string childDirectory = "child"; + + FileSystem.Initialize().WithSubdirectory(parentDirectory) + .Initialized(x => x.WithSubdirectory(childDirectory)); + + string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + + using ManualResetEventSlim deletedMs = new(); + FileSystemEventArgs? deletedResult = null; + + using ManualResetEventSlim createdMs = new(); + FileSystemEventArgs? createdResult = null; + + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + + fileSystemWatcher.Deleted += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + deletedResult = eventArgs; + deletedMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.Created += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + createdResult = eventArgs; + createdMs.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(oldPath, childDirectory); + + // wait double the amount in case the other event is handled first + await That(createdMs.Wait(ExpectTimeout * 2, TestContext.Current.CancellationToken)).IsTrue(); + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsEqualTo(includeSubdirectories); + + await That(createdResult).IsNotNull(); + + if (includeSubdirectories) + { + await That(deletedResult).IsNotNull(); + } + else + { + await That(deletedResult).IsNull(); + } + } +} 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..4210f4b9 --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs @@ -0,0 +1,175 @@ +using System.IO; +using System.Threading; + +namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; + +[FileSystemTests] +public partial class MoveTests +{ + [Fact] + public async Task ParentWatcher_MoveToParent_ShouldInvokeCreated() + { + // short names, otherwise the path will be too long + const string parentDirectory = "parent"; + const string childDirectory = "child"; + + FileSystem.Initialize().WithSubdirectory(parentDirectory) + .Initialized(s => s.WithSubdirectory(childDirectory)); + + string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + + using ManualResetEventSlim ms = new(); + FileSystemEventArgs? result = null; + + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + + fileSystemWatcher.Created += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + result = eventArgs; + ms.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = false; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(oldPath, childDirectory); + + await That(ms.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + + await That(result).IsNotNull(); + } + + [Fact] + public async Task ChildWatcher_MoveToChild_ShouldInvokeCreated() + { + // short names, otherwise the path will be too long + const string parentDirectory = "parent"; + const string childDirectory = "child"; + + FileSystem.Initialize().WithSubdirectories(parentDirectory, childDirectory); + + string newPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + + using ManualResetEventSlim ms = new(); + FileSystemEventArgs? result = null; + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(FileSystem.Path.Combine(BasePath, parentDirectory)); + + fileSystemWatcher.Created += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + result = eventArgs; + ms.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = false; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(childDirectory, newPath); + + await That(ms.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + + await That(result).IsNotNull(); + } + + [Fact] + public async Task ChildWatcher_MoveInPlace_ShouldInvokeRename() + { + // short names, otherwise the path will be too long + const string parentDirectory = "parent"; + const string childDirectory = "child"; + + FileSystem.Initialize().WithSubdirectory(parentDirectory) + .Initialized(x => x.WithSubdirectory(childDirectory)); + + string oldChildDirectory = FileSystem.Path.Combine(parentDirectory, childDirectory); + string newChildDirectory = FileSystem.Path.Combine(parentDirectory, "newChild"); + + using ManualResetEventSlim ms = new(); + FileSystemEventArgs? result = null; + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(FileSystem.Path.Combine(BasePath, parentDirectory)); + + fileSystemWatcher.Renamed += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + result = eventArgs; + ms.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = false; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(oldChildDirectory, newChildDirectory); + + await That(ms.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + + await That(result).IsNotNull(); + } + + [Fact] + public async Task ChildWatcher_MoveToParent_ShouldInvokeDeleted() + { + // short names, otherwise the path will be too long + const string parentDirectory = "parent"; + const string childDirectory = "child"; + + FileSystem.Initialize().WithSubdirectory(parentDirectory) + .Initialized(x => x.WithSubdirectory(childDirectory)); + + string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + + using ManualResetEventSlim ms = new(); + FileSystemEventArgs? result = null; + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(FileSystem.Path.Combine(BasePath, parentDirectory)); + + fileSystemWatcher.Deleted += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + result = eventArgs; + ms.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = false; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(oldPath, childDirectory); + + await That(ms.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + + await That(result).IsNotNull(); + } +} From 4fa2f41aae826d2601fc1709082165e26539d25e Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Fri, 16 Jan 2026 15:09:26 +0100 Subject: [PATCH 02/11] fix: fixed FileSystemWatcherMock not raising the correct events when moving files --- .../FileSystem/FileSystemWatcherMock.cs | 317 ++++++++++++++++-- 1 file changed, 283 insertions(+), 34 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs index e1bff42a..66f9d87e 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs @@ -5,6 +5,7 @@ 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 +276,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 +399,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 +535,262 @@ 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, + 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) + { + // Premise + // context.ComesFromOutside == true && context.GoesToInside == true covered + // context.ComesFromInside == true && context.GoesToInside == true covered + // context.GoesToOutside == true covered + + 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() { - TryMakeRenamedEventArgs(item, - out RenamedEventArgs? eventArgs); - if (eventArgs != null) + Deleted?.Invoke(this, ToFileSystemEventArgs(WatcherChangeTypes.Deleted, item.OldPath!)); + } + + void FireRenamed() + { + if (TryMakeRenamedEventArgs(item, out RenamedEventArgs? eventArgs)) { Renamed?.Invoke(this, eventArgs); } } } + private void TriggerMacRenameNotification(ChangeDescription item, RenamedContext context) + { + // Premise + // context.ComesFromOutside == true && context.GoesToInside == true covered + // context.ComesFromInside == true && context.GoesToInside == true covered + // context.GoesToOutside == true covered + + if (context.ComesFromInside && TryMakeRenamedEventArgs(item, out var eventArgs)) + { + Renamed?.Invoke(this, eventArgs); + return; + } + + TriggerLinuxRenameNotification(item, context); + } + + private void TriggerLinuxRenameNotification(ChangeDescription item, RenamedContext context) + { + // Premise + // context.ComesFromOutside == true && context.GoesToInside == true covered + // context.ComesFromInside == true && context.GoesToInside == true covered + // context.GoesToOutside == true covered + + 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 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 +812,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 +830,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 +840,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 +862,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 +958,33 @@ public WaitForChangedResultMock( public bool TimedOut { get; } } + [StructLayout(LayoutKind.Auto)] + private readonly struct RenamedContext( + bool comesFromOutside, + bool comesFromInside, + bool goesToInside, + int oldSubDirectoryCount + ) + { + private const int NestedLevelCount = 1; + + public bool ComesFromOutside { get; } = comesFromOutside; + + public bool ComesFromInside { get; } = comesFromInside; + + public bool GoesToInside { get; } = goesToInside; + + /// + /// 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 { From 0ef080c58be56313224c9a7c3f142155ee3d6ab8 Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Fri, 16 Jan 2026 15:10:19 +0100 Subject: [PATCH 03/11] test: correct item move tests for FileSystemWatcherMock --- .../FileSystemWatcher/MoveTests.Unix.cs | 382 ++++++++++-------- .../FileSystemWatcher/MoveTests.Windows.cs | 365 +++++++++-------- .../FileSystem/FileSystemWatcher/MoveTests.cs | 305 +++++++++----- 3 files changed, 599 insertions(+), 453 deletions(-) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs index 96167e3a..09126b39 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs @@ -6,265 +6,293 @@ namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; public partial class MoveTests { [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task Linux_ParentWatcher_MoveToChild_ShouldInvokeEitherDeletedOrRenamed( - bool includeSubdirectories + [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.IfNot(Test.RunsOnLinux); + Skip.If(Test.RunsOnWindows); + + // Arrange + + if (paths.Length == 0) + { + throw new ArgumentException("At least one path is required.", nameof(paths)); + } // short names, otherwise the path will be too long - const string parentDirectory = "parent"; - const string childDirectory = "child"; + const string outsideDirectory = "outside"; + const string insideDirectory = "inside"; + const string targetName = "target"; - FileSystem.Initialize().WithSubdirectories(parentDirectory, childDirectory); + string insideSubDirectory = FileSystem.Path.Combine( + insideDirectory, FileSystem.Path.Combine(paths) + ); - string newPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + string outsideTarget = FileSystem.Path.Combine(outsideDirectory, targetName); + string insideTarget = FileSystem.Path.Combine(insideSubDirectory, targetName); - using ManualResetEventSlim deletedMs = new(); - FileSystemEventArgs? deletedResult = null; + FileSystem.Initialize().WithSubdirectories(insideSubDirectory, outsideTarget); - using ManualResetEventSlim renamedMs = new(); - FileSystemEventArgs? renamedResult = null; + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); - using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + ); - fileSystemWatcher.Deleted += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - deletedResult = eventArgs; - deletedMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; - - fileSystemWatcher.Renamed += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - renamedResult = eventArgs; - renamedMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; + 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; - FileSystem.Directory.Move(childDirectory, newPath); + // Act + + FileSystem.Directory.Move(outsideTarget, insideTarget); - // wait triple the amount in case the other event is handled first - await That(deletedMs.Wait(ExpectTimeout * 2, TestContext.Current.CancellationToken)) - .IsNotEqualTo(includeSubdirectories); - - await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + // Assert + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(includeSubdirectories); - if (includeSubdirectories) - { - await That(deletedResult).IsNull(); - await That(renamedResult).IsNotNull(); - } - else - { - await That(deletedResult).IsNotNull(); - await That(renamedResult).IsNull(); - } + 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 ThatIsNullOrNot(createdBox.Value, !includeSubdirectories); } [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task MacOs_ParentWatcher_MoveToChild_ShouldInvokeRenamed( - bool includeSubdirectories + [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.IfNot(Test.RunsOnLinux); + Skip.If(Test.RunsOnWindows); + + if (paths.Length == 0) + { + throw new ArgumentException("At least one path is required.", nameof(paths)); + } + + // 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 parentDirectory = "parent"; - const string childDirectory = "child"; + const string insideDirectory = "inside"; + const string targetName = "target"; - FileSystem.Initialize().WithSubdirectories(parentDirectory, childDirectory); + string insideSubDirectory = FileSystem.Path.Combine( + insideDirectory, FileSystem.Path.Combine(paths) + ); - string newPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + string insideTarget = FileSystem.Path.Combine(insideDirectory, targetName); + string nestedTarget = FileSystem.Path.Combine(insideSubDirectory, targetName); - using ManualResetEventSlim renamedMs = new(); - FileSystemEventArgs? renamedResult = null; + FileSystem.Initialize().WithSubdirectories(insideSubDirectory, insideTarget); - using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); - fileSystemWatcher.Renamed += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - renamedResult = eventArgs; - renamedMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted, out EventBox deletedBox + ); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler( + fileSystemWatcher, out EventBox renamedBox + ); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created + ); + + using ManualResetEventSlim changedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Changed + ); fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; fileSystemWatcher.EnableRaisingEvents = true; - FileSystem.Directory.Move(childDirectory, newPath); + // 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(includeSubdirectories); + .IsEqualTo(isRenamed); - if (includeSubdirectories) - { - await That(renamedResult).IsNotNull(); - } - else - { - await That(renamedResult).IsNull(); - } + await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsNullOrNot(deletedBox.Value, isRenamed); + await ThatIsNullOrNot(renamedBox.Value, !isRenamed); } [Theory] [InlineData(true)] [InlineData(false)] - public async Task Unix_ParentWatcher_MoveInPlace_ShouldInvokeNothing(bool includeSubdirectories) + [InlineData(true, "deep")] + [InlineData(false, "deep")] + public async Task Unix_MoveNestedTo_ShouldInvokeCreatedOrRenamed( + bool includeSubdirectories, + string? path = null + ) { Skip.If(Test.RunsOnWindows); + + // Arrange + + bool isCreated = !includeSubdirectories && path is null; // short names, otherwise the path will be too long - const string parentDirectory = "parent"; - const string childDirectory = "child"; + const string insideDirectory = "inside"; + const string targetName = "target"; - FileSystem.Initialize().WithSubdirectory(parentDirectory) - .Initialized(x => x.WithSubdirectory(childDirectory)); + string nestedDirectory = FileSystem.Path.Combine(insideDirectory, "nested"); - string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); - string newPath = FileSystem.Path.Combine(parentDirectory, "newChild"); + string targetDir = path is null + ? insideDirectory + : FileSystem.Path.Combine(nestedDirectory, path); - using ManualResetEventSlim renamedMs = new(); - FileSystemEventArgs? renamedResult = null; + string target = FileSystem.Path.Combine(targetDir, targetName); - using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + string source = FileSystem.Path.Combine(nestedDirectory, targetName); - fileSystemWatcher.Renamed += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - renamedResult = eventArgs; - renamedMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; + FileSystem.Initialize().WithSubdirectories(targetDir, source); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler( + fileSystemWatcher, out EventBox renamedBox + ); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + ); + + using ManualResetEventSlim changedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Changed + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted + ); fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; fileSystemWatcher.EnableRaisingEvents = true; - FileSystem.Directory.Move(oldPath, newPath); + // 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); - if (includeSubdirectories) - { - await That(renamedResult).IsNotNull(); - } - else - { - await That(renamedResult).IsNull(); - } + await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsNullOrNot(createdBox.Value, !isCreated); + await ThatIsNullOrNot(renamedBox.Value, !includeSubdirectories); } [Theory] [InlineData(true)] [InlineData(false)] - public async Task Unix_ParentWatcher_MoveToParent_IncludeSubDirectories_ShouldInvokeCreated( - bool includeSubdirectories + [InlineData(true, "nested")] + [InlineData(false, "nested")] + public async Task Unix_MoveDeepNestedTo_ShouldInvokeRenamed( + bool includeSubdirectories, + string? path = null ) { Skip.If(Test.RunsOnWindows); + + // Arrange + + bool isCreated = !includeSubdirectories && path is null; // short names, otherwise the path will be too long - const string parentDirectory = "parent"; - const string childDirectory = "child"; + const string insideDirectory = "inside"; + const string targetName = "target"; + + string deepNestedDirectory = FileSystem.Path.Combine(insideDirectory, "nested", "deep"); - FileSystem.Initialize().WithSubdirectory(parentDirectory) - .Initialized(x => x.WithSubdirectory(childDirectory)); + string targetDir = path is null + ? insideDirectory + : FileSystem.Path.Combine(insideDirectory, path); - string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + string target = FileSystem.Path.Combine(targetDir, targetName); - using ManualResetEventSlim renamedMs = new(); - FileSystemEventArgs? renamedResult = null; + string source = FileSystem.Path.Combine(deepNestedDirectory, targetName); - using ManualResetEventSlim createdMs = new(); - FileSystemEventArgs? createdResult = null; + FileSystem.Initialize().WithSubdirectories(targetDir, source); - using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); - fileSystemWatcher.Renamed += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - renamedResult = eventArgs; - renamedMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; - - fileSystemWatcher.Created += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - createdResult = eventArgs; - createdMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; + using ManualResetEventSlim renamedMs = AddRenamedEventHandler( + fileSystemWatcher, out EventBox renamedBox + ); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + ); + + using ManualResetEventSlim changedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Changed + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted + ); fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; fileSystemWatcher.EnableRaisingEvents = true; - FileSystem.Directory.Move(oldPath, childDirectory); + // Act + + FileSystem.Directory.Move(source, target); + + // Assert - // wait double the amount in case the other event is handled first - await That(createdMs.Wait(ExpectTimeout * 2, TestContext.Current.CancellationToken)) - .IsNotEqualTo(includeSubdirectories); + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(isCreated); await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(includeSubdirectories); - if (includeSubdirectories) - { - await That(createdResult).IsNull(); - await That(renamedResult).IsNotNull(); - } - else - { - await That(createdResult).IsNotNull(); - await That(renamedResult).IsNull(); - } + await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsNullOrNot(createdBox.Value, !isCreated); + await ThatIsNullOrNot(renamedBox.Value, !includeSubdirectories); } } diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs index 65d571ef..689f2c06 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs @@ -6,252 +6,263 @@ namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; public partial class MoveTests { [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task Windows_ParentWatcher_MoveToChild_ShouldInvokeDeletedAndCreatedAndChanged( - bool includeSubdirectories + [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); + if (paths.Length == 0) + { + throw new ArgumentException("At least one path is required.", nameof(paths)); + } + + // Arrange + // short names, otherwise the path will be too long - const string parentDirectory = "parent"; - const string childDirectory = "child"; + const string outsideDirectory = "outside"; + const string insideDirectory = "inside"; + const string targetName = "target"; - FileSystem.Initialize().WithSubdirectories(parentDirectory, childDirectory); + string insideSubDirectory = FileSystem.Path.Combine( + insideDirectory, FileSystem.Path.Combine(paths) + ); - string newPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + string outsideTarget = FileSystem.Path.Combine(outsideDirectory, targetName); + string insideTarget = FileSystem.Path.Combine(insideSubDirectory, targetName); - using ManualResetEventSlim deletedMs = new(); - FileSystemEventArgs? deletedResult = null; + FileSystem.Initialize().WithSubdirectories(insideSubDirectory, outsideTarget); - using ManualResetEventSlim createdMs = new(); - FileSystemEventArgs? createdResult = null; + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); - using ManualResetEventSlim changedMs = new(); - FileSystemEventArgs? changedResult = null; + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + ); - using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted + ); - fileSystemWatcher.Deleted += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - deletedResult = eventArgs; - deletedMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; - - fileSystemWatcher.Created += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - createdResult = eventArgs; - createdMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; - - fileSystemWatcher.Changed += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - changedResult = eventArgs; - changedMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; fileSystemWatcher.EnableRaisingEvents = true; - FileSystem.Directory.Move(childDirectory, newPath); - - // wait triple the amount in case the other two events are handled first - await That(deletedMs.Wait(ExpectTimeout * 3, TestContext.Current.CancellationToken)) - .IsTrue(); - - await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + // Act + + FileSystem.Directory.Move(outsideTarget, insideTarget); + // Assert + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(includeSubdirectories); - await That(deletedResult).IsNotNull(); - await That(changedResult).IsNotNull(); + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - if (includeSubdirectories) - { - await That(createdResult).IsNotNull(); - } - else + await ThatIsNullOrNot(createdBox.Value, !includeSubdirectories); + } + + [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); + + if (paths.Length == 0) { - await That(createdResult).IsNull(); + throw new ArgumentException("At least one path is required.", nameof(paths)); } + + // Arrange + + // short names, otherwise the path will be too long + const string insideDirectory = "inside"; + const string targetName = "target"; + + string insideSubDirectory = FileSystem.Path.Combine( + insideDirectory, FileSystem.Path.Combine(paths) + ); + + 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 EventBox deletedBox + ); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + ); + + 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(deletedBox.Value).IsNotNull(); + + await ThatIsNullOrNot(createdBox.Value, !includeSubdirectories); } [Theory] [InlineData(true)] [InlineData(false)] - public async Task Windows_ParentWatcher_MoveInPlace_ShouldInvokeChanged( - bool includeSubdirectories + [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 parentDirectory = "parent"; - const string childDirectory = "child"; + const string insideDirectory = "inside"; + const string targetName = "target"; - FileSystem.Initialize().WithSubdirectory(parentDirectory) - .Initialized(x => x.WithSubdirectory(childDirectory)); + string nestedDirectory = FileSystem.Path.Combine(insideDirectory, "nested"); - string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); - string newPath = FileSystem.Path.Combine(parentDirectory, "newChild"); + string targetDir = path is null + ? insideDirectory + : FileSystem.Path.Combine(nestedDirectory, path); - using ManualResetEventSlim renamedMs = new(); - FileSystemEventArgs? renamedResult = null; + string target = FileSystem.Path.Combine(targetDir, targetName); - using ManualResetEventSlim changedMs = new(); - FileSystemEventArgs? changedResult = null; + string source = FileSystem.Path.Combine(nestedDirectory, targetName); - using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + FileSystem.Initialize().WithSubdirectories(targetDir, source); - fileSystemWatcher.Renamed += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - renamedResult = eventArgs; - renamedMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; - - fileSystemWatcher.Changed += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - changedResult = eventArgs; - changedMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted, out EventBox deletedBox + ); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; fileSystemWatcher.EnableRaisingEvents = true; - FileSystem.Directory.Move(oldPath, newPath); + // Act + + FileSystem.Directory.Move(source, target); - // wait double the amount in case the other event is handled first - await That(changedMs.Wait(ExpectTimeout * 2, TestContext.Current.CancellationToken)).IsTrue(); + // Assert + + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsEqualTo(isCreated); - await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(includeSubdirectories); - await That(changedResult).IsNotNull(); + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - if (includeSubdirectories) - { - await That(renamedResult).IsNotNull(); - } - else - { - await That(renamedResult).IsNull(); - } + await ThatIsNullOrNot(createdBox.Value, !isCreated); + await ThatIsNullOrNot(deletedBox.Value, !includeSubdirectories); } [Theory] [InlineData(true)] [InlineData(false)] - public async Task Windows_ParentWatcher_MoveToParent_IncludeSubDirectories_ShouldInvokeCreated( - bool includeSubdirectories + [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 parentDirectory = "parent"; - const string childDirectory = "child"; + const string insideDirectory = "inside"; + const string targetName = "target"; - FileSystem.Initialize().WithSubdirectory(parentDirectory) - .Initialized(x => x.WithSubdirectory(childDirectory)); + string deepNestedDirectory = FileSystem.Path.Combine(insideDirectory, "nested", "deep"); - string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + string targetDir = path is null + ? insideDirectory + : FileSystem.Path.Combine(insideDirectory, path); - using ManualResetEventSlim deletedMs = new(); - FileSystemEventArgs? deletedResult = null; + string target = FileSystem.Path.Combine(targetDir, targetName); - using ManualResetEventSlim createdMs = new(); - FileSystemEventArgs? createdResult = null; + string source = FileSystem.Path.Combine(deepNestedDirectory, targetName); - using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + FileSystem.Initialize().WithSubdirectories(targetDir, source); - fileSystemWatcher.Deleted += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - deletedResult = eventArgs; - deletedMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; - - fileSystemWatcher.Created += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - createdResult = eventArgs; - createdMs.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + ); + + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted, out EventBox deletedBox + ); + + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; fileSystemWatcher.EnableRaisingEvents = true; - FileSystem.Directory.Move(oldPath, childDirectory); - - // wait double the amount in case the other event is handled first - await That(createdMs.Wait(ExpectTimeout * 2, TestContext.Current.CancellationToken)).IsTrue(); - await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsEqualTo(includeSubdirectories); + // Act + + FileSystem.Directory.Move(source, target); - await That(createdResult).IsNotNull(); + // Assert - if (includeSubdirectories) - { - await That(deletedResult).IsNotNull(); - } - else - { - await That(deletedResult).IsNull(); - } + 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 ThatIsNullOrNot(createdBox.Value, !isCreated); + await ThatIsNullOrNot(deletedBox.Value, !includeSubdirectories); } } diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs index 4210f4b9..c08d769a 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs @@ -6,113 +6,220 @@ namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; [FileSystemTests] public partial class MoveTests { - [Fact] - public async Task ParentWatcher_MoveToParent_ShouldInvokeCreated() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task MoveOutsideToInside_ShouldInvokeCreated(bool includeSubdirectories) { + // Arrange + // short names, otherwise the path will be too long - const string parentDirectory = "parent"; - const string childDirectory = "child"; + 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().WithSubdirectory(parentDirectory) - .Initialized(s => s.WithSubdirectory(childDirectory)); + FileSystem.Initialize().WithSubdirectories( + outsideDirectory, insideDirectory, outsideTarget + ); - string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(insideDirectory); - using ManualResetEventSlim ms = new(); - FileSystemEventArgs? result = null; + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + ); - using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted + ); - fileSystemWatcher.Created += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - result = eventArgs; - ms.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); - fileSystemWatcher.IncludeSubdirectories = false; + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; fileSystemWatcher.EnableRaisingEvents = true; - FileSystem.Directory.Move(oldPath, childDirectory); + // Act + + FileSystem.Directory.Move(outsideTarget, insideTarget); + + // Assert - await That(ms.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); - await That(result).IsNotNull(); + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await That(createdBox.Value).IsNotNull(); } - [Fact] - public async Task ChildWatcher_MoveToChild_ShouldInvokeCreated() + [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 + ) { + // Arrange + + bool shouldInvokeDeleted = includeSubdirectories || paths.Length == 0; + // short names, otherwise the path will be too long - const string parentDirectory = "parent"; - const string childDirectory = "child"; + const string outsideDirectory = "outside"; + const string insideDirectory = "inside"; + const string targetName = "target"; - FileSystem.Initialize().WithSubdirectories(parentDirectory, childDirectory); + string insideSubDirectory = FileSystem.Path.Combine( + insideDirectory, FileSystem.Path.Combine(paths) + ); - string newPath = FileSystem.Path.Combine(parentDirectory, childDirectory); + string outsideTarget = FileSystem.Path.Combine(outsideDirectory, targetName); + string insideTarget = FileSystem.Path.Combine(insideSubDirectory, targetName); - using ManualResetEventSlim ms = new(); - FileSystemEventArgs? result = null; + FileSystem.Initialize().WithSubdirectories(outsideDirectory, insideDirectory, insideTarget); using IFileSystemWatcher fileSystemWatcher - = FileSystem.FileSystemWatcher.New(FileSystem.Path.Combine(BasePath, parentDirectory)); + = FileSystem.FileSystemWatcher.New(insideDirectory); - fileSystemWatcher.Created += (_, eventArgs) => - { - // ReSharper disable once AccessToDisposedClosure - try - { - result = eventArgs; - ms.Set(); - } - catch (ObjectDisposedException) - { - // Ignore any ObjectDisposedException - } - }; + using ManualResetEventSlim deletedMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Deleted, out EventBox deletedBox + ); + + using ManualResetEventSlim createdMs = AddEventHandler( + fileSystemWatcher, WatcherChangeTypes.Created + ); - fileSystemWatcher.IncludeSubdirectories = false; + using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; fileSystemWatcher.EnableRaisingEvents = true; - FileSystem.Directory.Move(childDirectory, newPath); + // Act + + FileSystem.Directory.Move(insideTarget, outsideTarget); + + // Assert - await That(ms.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(shouldInvokeDeleted); - await That(result).IsNotNull(); + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + + await ThatIsNullOrNot(deletedBox.Value, !shouldInvokeDeleted); } - [Fact] - public async Task ChildWatcher_MoveInPlace_ShouldInvokeRename() + [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 + ) { + // Arrange + + bool shouldInvokeRenamed = includeSubdirectories || paths.Length == 0; + // short names, otherwise the path will be too long - const string parentDirectory = "parent"; - const string childDirectory = "child"; - - FileSystem.Initialize().WithSubdirectory(parentDirectory) - .Initialized(x => x.WithSubdirectory(childDirectory)); - - string oldChildDirectory = FileSystem.Path.Combine(parentDirectory, childDirectory); - string newChildDirectory = FileSystem.Path.Combine(parentDirectory, "newChild"); - - using ManualResetEventSlim ms = new(); - FileSystemEventArgs? result = null; + 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); + + FileSystem.Initialize().WithSubdirectories(insideDirectory, insideTarget); using IFileSystemWatcher fileSystemWatcher - = FileSystem.FileSystemWatcher.New(FileSystem.Path.Combine(BasePath, parentDirectory)); + = FileSystem.FileSystemWatcher.New(insideDirectory); - fileSystemWatcher.Renamed += (_, eventArgs) => + using ManualResetEventSlim renamedMs = AddRenamedEventHandler( + fileSystemWatcher, out EventBox renamedBox + ); + + 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 ThatIsNullOrNot(renamedBox.Value, !shouldInvokeRenamed); + } + + private static async Task ThatIsNullOrNot(T? value, bool isNull) where T : class + { + if (isNull) + { + await That(value).IsNull(); + } + else + { + await That(value).IsNotNull(); + } + } + + 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 EventBox eventBox + ) + { + ManualResetEventSlim ms = new(); + EventBox box = new(); + + eventBox = box; + + FileSystemEventHandler handler = (_, args) => { // ReSharper disable once AccessToDisposedClosure try { - result = eventArgs; + box.Value = args; ms.Set(); } catch (ObjectDisposedException) @@ -121,40 +228,42 @@ public async Task ChildWatcher_MoveInPlace_ShouldInvokeRename() } }; - fileSystemWatcher.IncludeSubdirectories = false; - fileSystemWatcher.EnableRaisingEvents = true; + switch (changeType) + { + case WatcherChangeTypes.Created: + fileSystemWatcher.Created += handler; - FileSystem.Directory.Move(oldChildDirectory, newChildDirectory); + break; + case WatcherChangeTypes.Changed: + fileSystemWatcher.Changed += handler; - await That(ms.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + break; + case WatcherChangeTypes.Deleted: + fileSystemWatcher.Deleted += handler; - await That(result).IsNotNull(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(changeType), changeType, null); + } + + return ms; } - [Fact] - public async Task ChildWatcher_MoveToParent_ShouldInvokeDeleted() + private static ManualResetEventSlim AddRenamedEventHandler( + IFileSystemWatcher fileSystemWatcher, + out EventBox eventBox + ) { - // short names, otherwise the path will be too long - const string parentDirectory = "parent"; - const string childDirectory = "child"; - - FileSystem.Initialize().WithSubdirectory(parentDirectory) - .Initialized(x => x.WithSubdirectory(childDirectory)); + ManualResetEventSlim ms = new(); + EventBox box = new(); + eventBox = box; - string oldPath = FileSystem.Path.Combine(parentDirectory, childDirectory); - - using ManualResetEventSlim ms = new(); - FileSystemEventArgs? result = null; - - using IFileSystemWatcher fileSystemWatcher - = FileSystem.FileSystemWatcher.New(FileSystem.Path.Combine(BasePath, parentDirectory)); - - fileSystemWatcher.Deleted += (_, eventArgs) => + fileSystemWatcher.Renamed += (_, args) => { // ReSharper disable once AccessToDisposedClosure try { - result = eventArgs; + box.Value = args; ms.Set(); } catch (ObjectDisposedException) @@ -163,13 +272,11 @@ public async Task ChildWatcher_MoveToParent_ShouldInvokeDeleted() } }; - fileSystemWatcher.IncludeSubdirectories = false; - fileSystemWatcher.EnableRaisingEvents = true; - - FileSystem.Directory.Move(oldPath, childDirectory); - - await That(ms.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + return ms; + } - await That(result).IsNotNull(); + private class EventBox + { + public FileSystemEventArgs? Value { get; set; } } } From 268574efa5ab2d7dcd1a5b786b237534c49ff77b Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Fri, 16 Jan 2026 15:22:12 +0100 Subject: [PATCH 04/11] style: fixed code styling in FileSystemWatcherMock --- .../FileSystem/FileSystemWatcherMock.cs | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs index 66f9d87e..7c1f5858 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs @@ -643,8 +643,6 @@ private void TriggerWindowsRenameNotification(ChangeDescription item, RenamedCon FireCreated(); } } - - return; void FireCreated() { @@ -964,7 +962,7 @@ private readonly struct RenamedContext( bool comesFromInside, bool goesToInside, int oldSubDirectoryCount - ) + ) : IEquatable { private const int NestedLevelCount = 1; @@ -983,6 +981,33 @@ int oldSubDirectoryCount /// If this is then is /// public bool ComesFromDeepNested { get; } = oldSubDirectoryCount > NestedLevelCount; + + /// + public bool Equals(RenamedContext other) + => ComesFromOutside == other.ComesFromOutside + && ComesFromInside == other.ComesFromInside + && GoesToInside == other.GoesToInside + && ComesFromNested == other.ComesFromNested + && ComesFromDeepNested == other.ComesFromDeepNested; + + /// + public override bool Equals(object? obj) + => obj is RenamedContext other && Equals(other); + + /// + public override int GetHashCode() + { + unchecked + { + int hashCode = ComesFromOutside.GetHashCode(); + hashCode = (hashCode * 397) ^ ComesFromInside.GetHashCode(); + hashCode = (hashCode * 397) ^ GoesToInside.GetHashCode(); + hashCode = (hashCode * 397) ^ ComesFromNested.GetHashCode(); + hashCode = (hashCode * 397) ^ ComesFromDeepNested.GetHashCode(); + + return hashCode; + } + } } internal sealed class ChangeDescriptionEventArgs(ChangeDescription changeDescription) From 662a828503b065a2e8077c583b062cfd50a865be Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Mon, 19 Jan 2026 10:11:01 +0100 Subject: [PATCH 05/11] test: improved FileSystemWatcherMock tests by checking the event properties and fixed for mac --- .../FileSystemWatcher/MoveTests.Unix.cs | 187 ++++++++++++++---- .../FileSystemWatcher/MoveTests.Windows.cs | 175 ++++++++++++---- .../FileSystem/FileSystemWatcher/MoveTests.cs | 155 ++++++++++++--- 3 files changed, 408 insertions(+), 109 deletions(-) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs index 09126b39..43c7a49b 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.Collections.Concurrent; +using System.IO; using System.Threading; namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; @@ -16,22 +17,24 @@ params string[] paths ) { Skip.If(Test.RunsOnWindows); - - // Arrange if (paths.Length == 0) { throw new ArgumentException("At least one path is required.", nameof(paths)); } + // Arrange + // 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 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); @@ -42,7 +45,8 @@ params string[] paths = FileSystem.FileSystemWatcher.New(insideDirectory); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag ); using ManualResetEventSlim deletedMs = AddEventHandler( @@ -59,11 +63,11 @@ params string[] paths fileSystemWatcher.EnableRaisingEvents = true; // Act - + FileSystem.Directory.Move(outsideTarget, insideTarget); // Assert - + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(includeSubdirectories); @@ -71,7 +75,17 @@ await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - await ThatIsNullOrNot(createdBox.Value, !includeSubdirectories); + await ThatIsSingleOrEmpty(createdBag, !includeSubdirectories); + + if (includeSubdirectories) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created + && EqualsOrdinal(x.Name, expectedName) + && EqualsOrdinal(x.FullPath, insideTarget) + ); + } } [Theory] @@ -92,7 +106,7 @@ params string[] paths } // 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; @@ -100,9 +114,11 @@ params string[] paths const string insideDirectory = "inside"; const string targetName = "target"; - string insideSubDirectory = FileSystem.Path.Combine( - insideDirectory, FileSystem.Path.Combine(paths) - ); + 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); @@ -113,15 +129,17 @@ params string[] paths = FileSystem.FileSystemWatcher.New(insideDirectory); using ManualResetEventSlim deletedMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Deleted, out EventBox deletedBox + fileSystemWatcher, WatcherChangeTypes.Deleted, + out ConcurrentBag deletedBag ); using ManualResetEventSlim renamedMs = AddRenamedEventHandler( - fileSystemWatcher, out EventBox renamedBox + fileSystemWatcher, out ConcurrentBag renamedBag ); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag ); using ManualResetEventSlim changedMs = AddEventHandler( @@ -132,11 +150,11 @@ params string[] paths fileSystemWatcher.EnableRaisingEvents = true; // Act - + FileSystem.Directory.Move(insideTarget, nestedTarget); // Assert - + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(!isRenamed); @@ -144,10 +162,34 @@ 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 ThatIsNullOrNot(deletedBox.Value, isRenamed); - await ThatIsNullOrNot(renamedBox.Value, !isRenamed); + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(IsMac); + + await RemoveMacArrangeEvents(createdBag, insideTarget, insideSubDirectory, insideTarget); + + await ThatIsSingleOrEmpty(deletedBag, isRenamed); + await ThatIsSingleOrEmpty(renamedBag, !isRenamed); + + if (isRenamed) + { + await That(renamedBag.TryTake(out RenamedEventArgs? renamedEvent)).IsTrue(); + + await That(renamedEvent!).Satisfies(x => EqualsOrdinal(x.Name, expectedName) + && EqualsOrdinal(x.FullPath, nestedTarget) + && EqualsOrdinal(x.OldName, targetName) + && EqualsOrdinal(x.OldFullPath, insideTarget) + ); + } + else + { + await That(deletedBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Deleted + && EqualsOrdinal(x.Name, targetName) + && EqualsOrdinal(x.FullPath, insideTarget) + ); + } } [Theory] @@ -161,7 +203,7 @@ public async Task Unix_MoveNestedTo_ShouldInvokeCreatedOrRenamed( ) { Skip.If(Test.RunsOnWindows); - + // Arrange bool isCreated = !includeSubdirectories && path is null; @@ -169,8 +211,15 @@ public async Task Unix_MoveNestedTo_ShouldInvokeCreatedOrRenamed( // 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 nestedDirectory = FileSystem.Path.Combine(insideDirectory, "nested"); + 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 @@ -186,11 +235,12 @@ public async Task Unix_MoveNestedTo_ShouldInvokeCreatedOrRenamed( = FileSystem.FileSystemWatcher.New(insideDirectory); using ManualResetEventSlim renamedMs = AddRenamedEventHandler( - fileSystemWatcher, out EventBox renamedBox + fileSystemWatcher, out ConcurrentBag renamedBag ); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag ); using ManualResetEventSlim changedMs = AddEventHandler( @@ -205,13 +255,13 @@ public async Task Unix_MoveNestedTo_ShouldInvokeCreatedOrRenamed( fileSystemWatcher.EnableRaisingEvents = true; // Act - + FileSystem.Directory.Move(source, target); // Assert - + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) - .IsEqualTo(isCreated); + .IsEqualTo(isCreated || IsMac); await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(includeSubdirectories); @@ -219,8 +269,32 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - await ThatIsNullOrNot(createdBox.Value, !isCreated); - await ThatIsNullOrNot(renamedBox.Value, !includeSubdirectories); + await RemoveMacArrangeEvents(createdBag, target, targetDir, source); + + await ThatIsSingleOrEmpty(createdBag, !isCreated); + + if (isCreated) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created + && EqualsOrdinal(x.Name, expectedName) + && EqualsOrdinal(x.FullPath, target) + ); + } + + await ThatIsSingleOrEmpty(renamedBag, !includeSubdirectories); + + if (includeSubdirectories) + { + await That(renamedBag.TryTake(out RenamedEventArgs? renamedEvent)).IsTrue(); + + await That(renamedEvent!).Satisfies(x => EqualsOrdinal(x.Name, expectedName) + && EqualsOrdinal(x.FullPath, target) + && EqualsOrdinal(x.OldName, expectedOldName) + && EqualsOrdinal(x.OldFullPath, source) + ); + } } [Theory] @@ -228,13 +302,13 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) [InlineData(false)] [InlineData(true, "nested")] [InlineData(false, "nested")] - public async Task Unix_MoveDeepNestedTo_ShouldInvokeRenamed( + public async Task Unix_MoveDeepNestedTo_ShouldInvokeRenamedOrCreated( bool includeSubdirectories, string? path = null ) { Skip.If(Test.RunsOnWindows); - + // Arrange bool isCreated = !includeSubdirectories && path is null; @@ -242,8 +316,11 @@ public async Task Unix_MoveDeepNestedTo_ShouldInvokeRenamed( // short names, otherwise the path will be too long const string insideDirectory = "inside"; const string targetName = "target"; - - string deepNestedDirectory = FileSystem.Path.Combine(insideDirectory, "nested", "deep"); + + 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 @@ -251,7 +328,7 @@ public async Task Unix_MoveDeepNestedTo_ShouldInvokeRenamed( string target = FileSystem.Path.Combine(targetDir, targetName); - string source = FileSystem.Path.Combine(deepNestedDirectory, targetName); + string source = FileSystem.Path.Combine(insideDirectory, deepNestedDirectory, targetName); FileSystem.Initialize().WithSubdirectories(targetDir, source); @@ -259,11 +336,12 @@ public async Task Unix_MoveDeepNestedTo_ShouldInvokeRenamed( = FileSystem.FileSystemWatcher.New(insideDirectory); using ManualResetEventSlim renamedMs = AddRenamedEventHandler( - fileSystemWatcher, out EventBox renamedBox + fileSystemWatcher, out ConcurrentBag renamedBag ); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag ); using ManualResetEventSlim changedMs = AddEventHandler( @@ -278,9 +356,9 @@ public async Task Unix_MoveDeepNestedTo_ShouldInvokeRenamed( fileSystemWatcher.EnableRaisingEvents = true; // Act - + FileSystem.Directory.Move(source, target); - + // Assert await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) @@ -291,8 +369,31 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - - await ThatIsNullOrNot(createdBox.Value, !isCreated); - await ThatIsNullOrNot(renamedBox.Value, !includeSubdirectories); + + await RemoveMacArrangeEvents(createdBag, target, targetDir, source); + + await ThatIsSingleOrEmpty(createdBag, !isCreated); + await ThatIsSingleOrEmpty(renamedBag, !includeSubdirectories); + + if (isCreated) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created + && EqualsOrdinal(x.Name, expectedName) + && EqualsOrdinal(x.FullPath, target) + ); + } + + if (includeSubdirectories) + { + await That(renamedBag.TryTake(out RenamedEventArgs? renamedEvent)).IsTrue(); + + await That(renamedEvent!).Satisfies(x => EqualsOrdinal(x.Name, expectedName) + && EqualsOrdinal(x.FullPath, target) + && EqualsOrdinal(x.OldName, expectedOldName) + && EqualsOrdinal(x.OldFullPath, source) + ); + } } } diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs index 689f2c06..687bfabb 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.Collections.Concurrent; +using System.IO; using System.Threading; namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; @@ -21,7 +22,7 @@ params string[] paths { throw new ArgumentException("At least one path is required.", nameof(paths)); } - + // Arrange // short names, otherwise the path will be too long @@ -29,9 +30,11 @@ params string[] paths const string insideDirectory = "inside"; const string targetName = "target"; - string insideSubDirectory = FileSystem.Path.Combine( - insideDirectory, FileSystem.Path.Combine(paths) - ); + 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); @@ -42,7 +45,8 @@ params string[] paths = FileSystem.FileSystemWatcher.New(insideDirectory); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag ); using ManualResetEventSlim deletedMs = AddEventHandler( @@ -55,18 +59,28 @@ params string[] paths 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 ThatIsNullOrNot(createdBox.Value, !includeSubdirectories); + await ThatIsSingleOrEmpty(createdBag, !includeSubdirectories); + + if (includeSubdirectories) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created + && EqualsOrdinal(x.Name, expectedName) + && EqualsOrdinal(x.FullPath, insideTarget) + ); + } } [Theory] @@ -85,16 +99,18 @@ params string[] paths { throw new ArgumentException("At least one path is required.", nameof(paths)); } - + // Arrange // short names, otherwise the path will be too long const string insideDirectory = "inside"; const string targetName = "target"; - string insideSubDirectory = FileSystem.Path.Combine( - insideDirectory, FileSystem.Path.Combine(paths) - ); + 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); @@ -105,11 +121,13 @@ params string[] paths = FileSystem.FileSystemWatcher.New(insideDirectory); using ManualResetEventSlim deletedMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Deleted, out EventBox deletedBox + fileSystemWatcher, WatcherChangeTypes.Deleted, + out ConcurrentBag deletedBag ); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag ); using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); @@ -117,12 +135,12 @@ params string[] paths fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; fileSystemWatcher.EnableRaisingEvents = true; - // Act - + // Act + FileSystem.Directory.Move(insideTarget, nestedTarget); // Assert - + await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) @@ -130,9 +148,26 @@ await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - await That(deletedBox.Value).IsNotNull(); - - await ThatIsNullOrNot(createdBox.Value, !includeSubdirectories); + await That(deletedBag).HasSingle(); + + await That(deletedBag.TryTake(out FileSystemEventArgs? deletedEvent)).IsTrue(); + + await That(deletedEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Deleted + && EqualsOrdinal(x.Name, targetName) + && EqualsOrdinal(x.FullPath, insideTarget) + ); + + await ThatIsSingleOrEmpty(createdBag, !includeSubdirectories); + + if (includeSubdirectories) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created + && EqualsOrdinal(x.Name, expectedCreatedName) + && EqualsOrdinal(x.FullPath, nestedTarget) + ); + } } [Theory] @@ -146,7 +181,7 @@ public async Task Windows_MoveNestedTo_ShouldInvokeDeletedCreatedAndChanged( ) { Skip.IfNot(Test.RunsOnWindows); - + // Arrange bool isCreated = path is null || includeSubdirectories; @@ -154,8 +189,15 @@ public async Task Windows_MoveNestedTo_ShouldInvokeDeletedCreatedAndChanged( // 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, "nested"); + string nestedDirectory = FileSystem.Path.Combine(insideDirectory, nestedDirectoryName); string targetDir = path is null ? insideDirectory @@ -171,11 +213,13 @@ public async Task Windows_MoveNestedTo_ShouldInvokeDeletedCreatedAndChanged( = FileSystem.FileSystemWatcher.New(insideDirectory); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag ); using ManualResetEventSlim deletedMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Deleted, out EventBox deletedBox + fileSystemWatcher, WatcherChangeTypes.Deleted, + out ConcurrentBag deletedBag ); using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); @@ -184,20 +228,41 @@ public async Task Windows_MoveNestedTo_ShouldInvokeDeletedCreatedAndChanged( fileSystemWatcher.EnableRaisingEvents = true; // Act - + FileSystem.Directory.Move(source, target); // Assert - - await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsEqualTo(isCreated); + + 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 ThatIsNullOrNot(createdBox.Value, !isCreated); - await ThatIsNullOrNot(deletedBox.Value, !includeSubdirectories); + await ThatIsSingleOrEmpty(createdBag, !isCreated); + await ThatIsSingleOrEmpty(deletedBag, !includeSubdirectories); + + if (isCreated) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created + && EqualsOrdinal(x.Name, expectedCreatedName) + && EqualsOrdinal(x.FullPath, target) + ); + } + + if (includeSubdirectories) + { + await That(deletedBag.TryTake(out FileSystemEventArgs? deletedEvent)).IsTrue(); + + await That(deletedEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Deleted + && EqualsOrdinal(x.Name, expectedDeletedName) + && EqualsOrdinal(x.FullPath, source) + ); + } } [Theory] @@ -211,7 +276,7 @@ public async Task Windows_MoveDeepNestedTo_ShouldInvokeDeletedCreatedAndChanged( ) { Skip.IfNot(Test.RunsOnWindows); - + // Arrange bool isCreated = path is null || includeSubdirectories; @@ -219,8 +284,20 @@ public async Task Windows_MoveDeepNestedTo_ShouldInvokeDeletedCreatedAndChanged( // 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 deepNestedDirectory = FileSystem.Path.Combine(insideDirectory, "nested", "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 @@ -236,11 +313,13 @@ public async Task Windows_MoveDeepNestedTo_ShouldInvokeDeletedCreatedAndChanged( = FileSystem.FileSystemWatcher.New(insideDirectory); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag ); using ManualResetEventSlim deletedMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Deleted, out EventBox deletedBox + fileSystemWatcher, WatcherChangeTypes.Deleted, + out ConcurrentBag deletedBag ); using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); @@ -249,9 +328,9 @@ public async Task Windows_MoveDeepNestedTo_ShouldInvokeDeletedCreatedAndChanged( fileSystemWatcher.EnableRaisingEvents = true; // Act - + FileSystem.Directory.Move(source, target); - + // Assert await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) @@ -262,7 +341,27 @@ await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - await ThatIsNullOrNot(createdBox.Value, !isCreated); - await ThatIsNullOrNot(deletedBox.Value, !includeSubdirectories); + await ThatIsSingleOrEmpty(createdBag, !isCreated); + await ThatIsSingleOrEmpty(deletedBag, !includeSubdirectories); + + if (isCreated) + { + await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); + + await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created + && EqualsOrdinal(x.Name, expectedCreatedName) + && EqualsOrdinal(x.FullPath, target) + ); + } + + if (includeSubdirectories) + { + await That(deletedBag.TryTake(out FileSystemEventArgs? deletedEvent)).IsTrue(); + + await That(deletedEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Deleted + && EqualsOrdinal(x.Name, expectedDeletedName) + && EqualsOrdinal(x.FullPath, source) + ); + } } } diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs index c08d769a..a7bfa3b9 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs @@ -1,4 +1,8 @@ -using System.IO; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; using System.Threading; namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; @@ -6,6 +10,8 @@ namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; [FileSystemTests] public partial class MoveTests { + private static bool IsMac { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + [Theory] [InlineData(true)] [InlineData(false)] @@ -28,7 +34,8 @@ public async Task MoveOutsideToInside_ShouldInvokeCreated(bool includeSubdirecto = FileSystem.FileSystemWatcher.New(insideDirectory); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created, out EventBox createdBox + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag ); using ManualResetEventSlim deletedMs = AddEventHandler( @@ -51,7 +58,11 @@ public async Task MoveOutsideToInside_ShouldInvokeCreated(bool includeSubdirecto await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - await That(createdBox.Value).IsNotNull(); + await That(createdBag).HasSingle().Which + .Satisfies(x => x.ChangeType == WatcherChangeTypes.Created + && string.Equals(x.Name, targetName, StringComparison.Ordinal) + && string.Equals(x.FullPath, insideTarget, StringComparison.Ordinal) + ); } [Theory] @@ -82,17 +93,22 @@ params string[] 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 EventBox deletedBox + fileSystemWatcher, WatcherChangeTypes.Deleted, + out ConcurrentBag deletedBag ); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag ); using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); @@ -109,10 +125,30 @@ params string[] paths await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(shouldInvokeDeleted); - await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(IsMac); + await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - await ThatIsNullOrNot(deletedBox.Value, !shouldInvokeDeleted); + await RemoveMacArrangeEvents(createdBag, insideTarget, insideDirectory, insideTarget); + + await ThatIsSingleOrEmpty(deletedBag, !shouldInvokeDeleted); + + if (shouldInvokeDeleted) + { + await That(deletedBag.TryTake(out FileSystemEventArgs? deletedEvent)).IsTrue(); + + await That(deletedEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Deleted + && string.Equals( + x.Name, expectedDeletedName, + StringComparison.Ordinal + ) + && string.Equals( + x.FullPath, insideTarget, + StringComparison.Ordinal + ) + ); + } } [Theory] @@ -143,13 +179,19 @@ params string[] 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 EventBox renamedBox + fileSystemWatcher, out ConcurrentBag renamedBag ); using ManualResetEventSlim deletedMs = AddEventHandler( @@ -157,7 +199,8 @@ params string[] paths ); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created + fileSystemWatcher, WatcherChangeTypes.Created, + out ConcurrentBag createdBag ); fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; @@ -173,20 +216,81 @@ 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 ThatIsNullOrNot(renamedBox.Value, !shouldInvokeRenamed); + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) + .IsEqualTo(IsMac); + + await RemoveMacArrangeEvents(createdBag, insideTarget, insideDirectory, insideTarget); + + await ThatIsSingleOrEmpty(renamedBag, !shouldInvokeRenamed); + + if (shouldInvokeRenamed) + { + await That(renamedBag.TryTake(out RenamedEventArgs? renamedEvent)).IsTrue(); + + await That(renamedEvent!) + .Satisfies(x => string.Equals(x.OldName, expectedOldName, StringComparison.Ordinal) + && string.Equals(x.Name, expectedName, StringComparison.Ordinal) + && string.Equals( + x.FullPath, insideTarget2, StringComparison.Ordinal + ) + && string.Equals( + x.OldFullPath, insideTarget, StringComparison.Ordinal + ) + ); + } } - private static async Task ThatIsNullOrNot(T? value, bool isNull) where T : class + private static bool EqualsOrdinal(string? x, string? y) { - if (isNull) + return string.Equals(x, y, StringComparison.Ordinal); + } + + private static async Task ThatIsSingleOrEmpty(IEnumerable value, bool isEmpty) + where T : class + { + if (isEmpty) { - await That(value).IsNull(); + await That(value).IsEmpty(); } else { - await That(value).IsNotNull(); + await That(value).HasSingle(); + } + } + + private static async Task RemoveMacArrangeEvents( + ConcurrentBag createdBag, + string expectedFullPath, + params string[] initialDirectories + ) + { + if (!IsMac) + { + return; + } + + FileSystemEventArgs? expectedEvent = null; + + while (createdBag.TryTake(out FileSystemEventArgs? createdEvent)) + { + if (createdEvent.ChangeType == WatcherChangeTypes.Created + && EqualsOrdinal(createdEvent.FullPath, expectedFullPath)) + { + expectedEvent = createdEvent; + } + + await That(createdEvent) + .Satisfies(x => initialDirectories.Any(directory => EqualsOrdinal(directory, x.Name) + ) + ); + } + + await That(createdBag).IsEmpty(); + + if (expectedEvent is not null) + { + createdBag.Add(expectedEvent); } } @@ -206,20 +310,20 @@ private static ManualResetEventSlim AddRenamedEventHandler(IFileSystemWatcher fi private static ManualResetEventSlim AddEventHandler( IFileSystemWatcher fileSystemWatcher, WatcherChangeTypes changeType, - out EventBox eventBox + out ConcurrentBag events ) { ManualResetEventSlim ms = new(); - EventBox box = new(); + ConcurrentBag eventBag = new(); - eventBox = box; + events = eventBag; FileSystemEventHandler handler = (_, args) => { // ReSharper disable once AccessToDisposedClosure try { - box.Value = args; + eventBag.Add(args); ms.Set(); } catch (ObjectDisposedException) @@ -251,19 +355,19 @@ out EventBox eventBox private static ManualResetEventSlim AddRenamedEventHandler( IFileSystemWatcher fileSystemWatcher, - out EventBox eventBox + out ConcurrentBag events ) { ManualResetEventSlim ms = new(); - EventBox box = new(); - eventBox = box; + ConcurrentBag eventBag = new(); + events = eventBag; fileSystemWatcher.Renamed += (_, args) => { // ReSharper disable once AccessToDisposedClosure try { - box.Value = args; + eventBag.Add(args); ms.Set(); } catch (ObjectDisposedException) @@ -274,9 +378,4 @@ out EventBox eventBox return ms; } - - private class EventBox - { - public FileSystemEventArgs? Value { get; set; } - } } From e79d45632f77bfd6af994566db766a60868522e8 Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Mon, 19 Jan 2026 11:48:42 +0100 Subject: [PATCH 06/11] test: fixed FileSystemWatcherMock tests for Mac --- .../FileSystemWatcher/MoveTests.Unix.cs | 4 +-- .../FileSystem/FileSystemWatcher/MoveTests.cs | 26 ++++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs index 43c7a49b..582d3d9e 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs @@ -166,7 +166,7 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(IsMac); - await RemoveMacArrangeEvents(createdBag, insideTarget, insideSubDirectory, insideTarget); + await RemoveMacArrangeEvents(createdBag, string.Empty /*None expected*/, insideSubDirectory, insideTarget); await ThatIsSingleOrEmpty(deletedBag, isRenamed); await ThatIsSingleOrEmpty(renamedBag, !isRenamed); @@ -362,7 +362,7 @@ out ConcurrentBag createdBag // Assert await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) - .IsEqualTo(isCreated); + .IsEqualTo(IsMac || isCreated); await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(includeSubdirectories); diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs index a7bfa3b9..45ef84b6 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs @@ -10,8 +10,13 @@ namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; [FileSystemTests] public partial class MoveTests { - private static bool IsMac { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); - + protected MoveTests() + { + IsMac = this is RealFileSystemTests && RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + } + + private bool IsMac { get; } + [Theory] [InlineData(true)] [InlineData(false)] @@ -218,9 +223,9 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) - .IsEqualTo(IsMac); + .IsEqualTo(IsMac && shouldInvokeRenamed); - await RemoveMacArrangeEvents(createdBag, insideTarget, insideDirectory, insideTarget); + await RemoveMacArrangeEvents(createdBag, string.Empty /*None expected*/, insideTarget); await ThatIsSingleOrEmpty(renamedBag, !shouldInvokeRenamed); @@ -259,7 +264,7 @@ private static async Task ThatIsSingleOrEmpty(IEnumerable value, bool isEm } } - private static async Task RemoveMacArrangeEvents( + private async Task RemoveMacArrangeEvents( ConcurrentBag createdBag, string expectedFullPath, params string[] initialDirectories @@ -278,11 +283,20 @@ params string[] initialDirectories && EqualsOrdinal(createdEvent.FullPath, expectedFullPath)) { expectedEvent = createdEvent; + + continue; } await That(createdEvent) - .Satisfies(x => initialDirectories.Any(directory => EqualsOrdinal(directory, x.Name) + .Satisfies(x => initialDirectories.Any(directory => EqualsOrdinal( + directory, x.FullPath + ) ) + ).Because( + nameof(createdEvent.FullPath) + + " should be one of the initial directories: [" + + string.Join(", ", initialDirectories) + + "]" ); } From 84e91bf2d845b0b12674f4f6ffa040243d0a804f Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Mon, 19 Jan 2026 12:48:01 +0100 Subject: [PATCH 07/11] test: fixed FileSystemWatcher.MoveTests.IsMac not being true for mac tests --- .../FileSystem/FileSystemWatcher/MoveTests.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs index 45ef84b6..8ad01604 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs @@ -10,13 +10,17 @@ namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; [FileSystemTests] public partial class MoveTests { - protected MoveTests() + private bool? _isMac; + + private bool IsMac { - IsMac = this is RealFileSystemTests && RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + get + { + _isMac ??= this is RealFileSystemTests && RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + return _isMac.Value; + } } - private bool IsMac { get; } - [Theory] [InlineData(true)] [InlineData(false)] From 1f4ddd795928e16b6ea7ab2e2c0ff001837ce075 Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Mon, 19 Jan 2026 14:11:48 +0100 Subject: [PATCH 08/11] test: fixed FileSystemWatcherMock tests expecting created events on excluded items --- .../FileSystem/FileSystemWatcher/MoveTests.Unix.cs | 10 +++++++--- .../FileSystem/FileSystemWatcher/MoveTests.cs | 9 ++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs index 582d3d9e..41de3275 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs @@ -166,7 +166,9 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(IsMac); - await RemoveMacArrangeEvents(createdBag, string.Empty /*None expected*/, insideSubDirectory, insideTarget); + await RemoveMacArrangeEvents( + createdBag, string.Empty /*None expected*/, insideSubDirectory, insideTarget + ); await ThatIsSingleOrEmpty(deletedBag, isRenamed); await ThatIsSingleOrEmpty(renamedBag, !isRenamed); @@ -207,6 +209,7 @@ public async Task Unix_MoveNestedTo_ShouldInvokeCreatedOrRenamed( // Arrange bool isCreated = !includeSubdirectories && path is null; + bool isMacCreated = IsMac && includeSubdirectories; // short names, otherwise the path will be too long const string insideDirectory = "inside"; @@ -261,7 +264,7 @@ out ConcurrentBag createdBag // Assert await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) - .IsEqualTo(isCreated || IsMac); + .IsEqualTo(isCreated || isMacCreated); await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(includeSubdirectories); @@ -312,6 +315,7 @@ public async Task Unix_MoveDeepNestedTo_ShouldInvokeRenamedOrCreated( // Arrange bool isCreated = !includeSubdirectories && path is null; + bool isMacCreated = IsMac && includeSubdirectories; // short names, otherwise the path will be too long const string insideDirectory = "inside"; @@ -362,7 +366,7 @@ out ConcurrentBag createdBag // Assert await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) - .IsEqualTo(IsMac || isCreated); + .IsEqualTo(isMacCreated || isCreated); await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(includeSubdirectories); diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs index 8ad01604..16f16c4f 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs @@ -11,12 +11,14 @@ namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; public partial class MoveTests { private bool? _isMac; - + private bool IsMac { get { - _isMac ??= this is RealFileSystemTests && RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + _isMac ??= this is RealFileSystemTests + && RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + return _isMac.Value; } } @@ -89,6 +91,7 @@ params string[] paths // Arrange bool shouldInvokeDeleted = includeSubdirectories || paths.Length == 0; + bool shouldInvokeCreated = IsMac && shouldInvokeDeleted; // short names, otherwise the path will be too long const string outsideDirectory = "outside"; @@ -135,7 +138,7 @@ await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(shouldInvokeDeleted); await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) - .IsEqualTo(IsMac); + .IsEqualTo(shouldInvokeCreated); await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); From da2149fe34a27d82f582eb5cf25f02233849ca52 Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Tue, 20 Jan 2026 13:24:37 +0100 Subject: [PATCH 09/11] test: cleaned up FileSystemWatcherMock tests --- .../FileSystemWatcher/MoveTests.Unix.cs | 89 ++++++++++--------- .../FileSystemWatcher/MoveTests.Windows.cs | 80 +++++++++-------- .../FileSystem/FileSystemWatcher/MoveTests.cs | 57 +++++------- 3 files changed, 114 insertions(+), 112 deletions(-) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs index 41de3275..032d91b3 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs @@ -18,11 +18,6 @@ params string[] paths { Skip.If(Test.RunsOnWindows); - if (paths.Length == 0) - { - throw new ArgumentException("At least one path is required.", nameof(paths)); - } - // Arrange // short names, otherwise the path will be too long @@ -81,10 +76,12 @@ await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) { await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); - await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created - && EqualsOrdinal(x.Name, expectedName) - && EqualsOrdinal(x.FullPath, insideTarget) - ); + 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)); } } @@ -100,11 +97,6 @@ params string[] paths { Skip.If(Test.RunsOnWindows); - if (paths.Length == 0) - { - throw new ArgumentException("At least one path is required.", nameof(paths)); - } - // Arrange // When moving items from inside to nested on Mac when IncludeSubdirectories is false, then it will invoke a Renamed rather than Deleted @@ -177,20 +169,25 @@ await RemoveMacArrangeEvents( { await That(renamedBag.TryTake(out RenamedEventArgs? renamedEvent)).IsTrue(); - await That(renamedEvent!).Satisfies(x => EqualsOrdinal(x.Name, expectedName) - && EqualsOrdinal(x.FullPath, nestedTarget) - && EqualsOrdinal(x.OldName, targetName) - && EqualsOrdinal(x.OldFullPath, insideTarget) - ); + 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!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Deleted - && EqualsOrdinal(x.Name, targetName) - && EqualsOrdinal(x.FullPath, insideTarget) - ); + 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)); } } @@ -280,10 +277,12 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) { await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); - await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created - && EqualsOrdinal(x.Name, expectedName) - && EqualsOrdinal(x.FullPath, target) - ); + 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); @@ -292,11 +291,14 @@ await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Crea { await That(renamedBag.TryTake(out RenamedEventArgs? renamedEvent)).IsTrue(); - await That(renamedEvent!).Satisfies(x => EqualsOrdinal(x.Name, expectedName) - && EqualsOrdinal(x.FullPath, target) - && EqualsOrdinal(x.OldName, expectedOldName) - && EqualsOrdinal(x.OldFullPath, source) - ); + 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)); } } @@ -383,21 +385,26 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) { await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); - await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created - && EqualsOrdinal(x.Name, expectedName) - && EqualsOrdinal(x.FullPath, target) - ); + 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!).Satisfies(x => EqualsOrdinal(x.Name, expectedName) - && EqualsOrdinal(x.FullPath, target) - && EqualsOrdinal(x.OldName, expectedOldName) - && EqualsOrdinal(x.OldFullPath, source) - ); + 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 index 687bfabb..50d3203b 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Windows.cs @@ -18,11 +18,6 @@ params string[] paths { Skip.IfNot(Test.RunsOnWindows); - if (paths.Length == 0) - { - throw new ArgumentException("At least one path is required.", nameof(paths)); - } - // Arrange // short names, otherwise the path will be too long @@ -76,10 +71,12 @@ await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) { await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); - await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created - && EqualsOrdinal(x.Name, expectedName) - && EqualsOrdinal(x.FullPath, insideTarget) - ); + 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)); } } @@ -95,11 +92,6 @@ params string[] paths { Skip.IfNot(Test.RunsOnWindows); - if (paths.Length == 0) - { - throw new ArgumentException("At least one path is required.", nameof(paths)); - } - // Arrange // short names, otherwise the path will be too long @@ -152,10 +144,12 @@ await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(deletedBag.TryTake(out FileSystemEventArgs? deletedEvent)).IsTrue(); - await That(deletedEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Deleted - && EqualsOrdinal(x.Name, targetName) - && EqualsOrdinal(x.FullPath, insideTarget) - ); + 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); @@ -163,10 +157,12 @@ await That(deletedEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Dele { await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); - await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created - && EqualsOrdinal(x.Name, expectedCreatedName) - && EqualsOrdinal(x.FullPath, nestedTarget) - ); + 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)); } } @@ -248,20 +244,24 @@ await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) { await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); - await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created - && EqualsOrdinal(x.Name, expectedCreatedName) - && EqualsOrdinal(x.FullPath, target) - ); + 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!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Deleted - && EqualsOrdinal(x.Name, expectedDeletedName) - && EqualsOrdinal(x.FullPath, source) - ); + 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)); } } @@ -348,20 +348,24 @@ await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) { await That(createdBag.TryTake(out FileSystemEventArgs? createdEvent)).IsTrue(); - await That(createdEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Created - && EqualsOrdinal(x.Name, expectedCreatedName) - && EqualsOrdinal(x.FullPath, target) - ); + 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!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Deleted - && EqualsOrdinal(x.Name, expectedDeletedName) - && EqualsOrdinal(x.FullPath, source) - ); + 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 index 16f16c4f..37635bcb 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs @@ -70,10 +70,11 @@ out ConcurrentBag createdBag await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); await That(createdBag).HasSingle().Which - .Satisfies(x => x.ChangeType == WatcherChangeTypes.Created - && string.Equals(x.Name, targetName, StringComparison.Ordinal) - && string.Equals(x.FullPath, insideTarget, StringComparison.Ordinal) - ); + .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] @@ -150,16 +151,12 @@ await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) { await That(deletedBag.TryTake(out FileSystemEventArgs? deletedEvent)).IsTrue(); - await That(deletedEvent!).Satisfies(x => x.ChangeType == WatcherChangeTypes.Deleted - && string.Equals( - x.Name, expectedDeletedName, - StringComparison.Ordinal - ) - && string.Equals( - x.FullPath, insideTarget, - StringComparison.Ordinal - ) - ); + 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)); } } @@ -241,27 +238,20 @@ await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(renamedBag.TryTake(out RenamedEventArgs? renamedEvent)).IsTrue(); await That(renamedEvent!) - .Satisfies(x => string.Equals(x.OldName, expectedOldName, StringComparison.Ordinal) - && string.Equals(x.Name, expectedName, StringComparison.Ordinal) - && string.Equals( - x.FullPath, insideTarget2, StringComparison.Ordinal - ) - && string.Equals( - x.OldFullPath, insideTarget, StringComparison.Ordinal - ) - ); + .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 bool EqualsOrdinal(string? x, string? y) - { - return string.Equals(x, y, StringComparison.Ordinal); - } - - private static async Task ThatIsSingleOrEmpty(IEnumerable value, bool isEmpty) + private static async Task ThatIsSingleOrEmpty(IEnumerable value, bool expectEmpty) where T : class { - if (isEmpty) + if (expectEmpty) { await That(value).IsEmpty(); } @@ -287,7 +277,7 @@ params string[] initialDirectories while (createdBag.TryTake(out FileSystemEventArgs? createdEvent)) { if (createdEvent.ChangeType == WatcherChangeTypes.Created - && EqualsOrdinal(createdEvent.FullPath, expectedFullPath)) + && string.Equals(createdEvent.FullPath, expectedFullPath, StringComparison.Ordinal)) { expectedEvent = createdEvent; @@ -295,8 +285,9 @@ params string[] initialDirectories } await That(createdEvent) - .Satisfies(x => initialDirectories.Any(directory => EqualsOrdinal( - directory, x.FullPath + .Satisfies(x => initialDirectories.Any(directory => string.Equals( + directory, x.FullPath, + StringComparison.Ordinal ) ) ).Because( From dc4b4d7e155c5e23820034bcb72e330622f28db7 Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Wed, 21 Jan 2026 12:46:51 +0100 Subject: [PATCH 10/11] refactor: cleaned up FileSystemWatcherMock and replaced premise comments with Debug.Assert --- .../FileSystem/FileSystemWatcherMock.cs | 75 ++++++++----------- 1 file changed, 31 insertions(+), 44 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs index 7c1f5858..63ab5845 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs @@ -1,6 +1,7 @@ using System; using System.Collections.ObjectModel; using System.ComponentModel; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; @@ -572,9 +573,9 @@ private void TriggerRenameNotification(ChangeDescription item) return; } - + RenamedContext context = new( - comesFromOutside, comesFromInside, goesToInside, + comesFromOutside, comesFromInside, goesToInside, goesToOutside, GetSubDirectoryCount(item.OldPath!) ); @@ -601,10 +602,7 @@ private void TriggerRenameNotification(ChangeDescription item) private void TriggerWindowsRenameNotification(ChangeDescription item, RenamedContext context) { - // Premise - // context.ComesFromOutside == true && context.GoesToInside == true covered - // context.ComesFromInside == true && context.GoesToInside == true covered - // context.GoesToOutside == true covered + CheckRenamePremise(context); if (context.ComesFromOutside) { @@ -643,7 +641,9 @@ private void TriggerWindowsRenameNotification(ChangeDescription item, RenamedCon FireCreated(); } } - + + return; + void FireCreated() { Created?.Invoke(this, ToFileSystemEventArgs(WatcherChangeTypes.Created, item.Path)); @@ -665,12 +665,9 @@ void FireRenamed() private void TriggerMacRenameNotification(ChangeDescription item, RenamedContext context) { - // Premise - // context.ComesFromOutside == true && context.GoesToInside == true covered - // context.ComesFromInside == true && context.GoesToInside == true covered - // context.GoesToOutside == true covered + CheckRenamePremise(context); - if (context.ComesFromInside && TryMakeRenamedEventArgs(item, out var eventArgs)) + if (context.ComesFromInside && TryMakeRenamedEventArgs(item, out RenamedEventArgs? eventArgs)) { Renamed?.Invoke(this, eventArgs); return; @@ -681,10 +678,7 @@ private void TriggerMacRenameNotification(ChangeDescription item, RenamedContext private void TriggerLinuxRenameNotification(ChangeDescription item, RenamedContext context) { - // Premise - // context.ComesFromOutside == true && context.GoesToInside == true covered - // context.ComesFromInside == true && context.GoesToInside == true covered - // context.GoesToOutside == true covered + CheckRenamePremise(context); bool hasRenameArgs = TryMakeRenamedEventArgs(item, out RenamedEventArgs? eventArgs); @@ -725,6 +719,23 @@ private void TriggerLinuxRenameNotification(ChangeDescription item, RenamedConte } } + 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) @@ -961,8 +972,9 @@ private readonly struct RenamedContext( bool comesFromOutside, bool comesFromInside, bool goesToInside, + bool goesToOutside, int oldSubDirectoryCount - ) : IEquatable + ) { private const int NestedLevelCount = 1; @@ -972,6 +984,8 @@ int oldSubDirectoryCount public bool GoesToInside { get; } = goesToInside; + public bool GoesToOutside { get; } = goesToOutside; + /// /// If this is then is /// @@ -981,33 +995,6 @@ int oldSubDirectoryCount /// If this is then is /// public bool ComesFromDeepNested { get; } = oldSubDirectoryCount > NestedLevelCount; - - /// - public bool Equals(RenamedContext other) - => ComesFromOutside == other.ComesFromOutside - && ComesFromInside == other.ComesFromInside - && GoesToInside == other.GoesToInside - && ComesFromNested == other.ComesFromNested - && ComesFromDeepNested == other.ComesFromDeepNested; - - /// - public override bool Equals(object? obj) - => obj is RenamedContext other && Equals(other); - - /// - public override int GetHashCode() - { - unchecked - { - int hashCode = ComesFromOutside.GetHashCode(); - hashCode = (hashCode * 397) ^ ComesFromInside.GetHashCode(); - hashCode = (hashCode * 397) ^ GoesToInside.GetHashCode(); - hashCode = (hashCode * 397) ^ ComesFromNested.GetHashCode(); - hashCode = (hashCode * 397) ^ ComesFromDeepNested.GetHashCode(); - - return hashCode; - } - } } internal sealed class ChangeDescriptionEventArgs(ChangeDescription changeDescription) From 721c491972487dc2b5e84bf3e87bbfae53b419df Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Wed, 21 Jan 2026 12:47:27 +0100 Subject: [PATCH 11/11] test: cleaned up FileSystemWatcherMock.MoveTests --- .../FileSystemWatcher/MoveTests.Unix.cs | 23 ++---- .../FileSystem/FileSystemWatcher/MoveTests.cs | 81 ++----------------- 2 files changed, 15 insertions(+), 89 deletions(-) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs index 032d91b3..ccdf9162 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.Unix.cs @@ -96,6 +96,7 @@ params string[] paths ) { Skip.If(Test.RunsOnWindows); + SkipIfBrittleTestsShouldBeSkipped(Test.RunsOnMac); // Arrange @@ -130,8 +131,7 @@ out ConcurrentBag deletedBag ); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created, - out ConcurrentBag createdBag + fileSystemWatcher, WatcherChangeTypes.Created ); using ManualResetEventSlim changedMs = AddEventHandler( @@ -155,12 +155,7 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) - .IsEqualTo(IsMac); - - await RemoveMacArrangeEvents( - createdBag, string.Empty /*None expected*/, insideSubDirectory, insideTarget - ); + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); await ThatIsSingleOrEmpty(deletedBag, isRenamed); await ThatIsSingleOrEmpty(renamedBag, !isRenamed); @@ -202,11 +197,11 @@ public async Task Unix_MoveNestedTo_ShouldInvokeCreatedOrRenamed( ) { Skip.If(Test.RunsOnWindows); + SkipIfBrittleTestsShouldBeSkipped(Test.RunsOnMac); // Arrange bool isCreated = !includeSubdirectories && path is null; - bool isMacCreated = IsMac && includeSubdirectories; // short names, otherwise the path will be too long const string insideDirectory = "inside"; @@ -261,7 +256,7 @@ out ConcurrentBag createdBag // Assert await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) - .IsEqualTo(isCreated || isMacCreated); + .IsEqualTo(isCreated); await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(includeSubdirectories); @@ -269,8 +264,6 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - await RemoveMacArrangeEvents(createdBag, target, targetDir, source); - await ThatIsSingleOrEmpty(createdBag, !isCreated); if (isCreated) @@ -313,11 +306,11 @@ public async Task Unix_MoveDeepNestedTo_ShouldInvokeRenamedOrCreated( ) { Skip.If(Test.RunsOnWindows); + SkipIfBrittleTestsShouldBeSkipped(Test.RunsOnMac); // Arrange bool isCreated = !includeSubdirectories && path is null; - bool isMacCreated = IsMac && includeSubdirectories; // short names, otherwise the path will be too long const string insideDirectory = "inside"; @@ -368,7 +361,7 @@ out ConcurrentBag createdBag // Assert await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) - .IsEqualTo(isMacCreated || isCreated); + .IsEqualTo(isCreated); await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(includeSubdirectories); @@ -376,8 +369,6 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(changedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - await RemoveMacArrangeEvents(createdBag, target, targetDir, source); - await ThatIsSingleOrEmpty(createdBag, !isCreated); await ThatIsSingleOrEmpty(renamedBag, !includeSubdirectories); diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs index 37635bcb..15992e86 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/MoveTests.cs @@ -1,8 +1,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Runtime.InteropServices; using System.Threading; namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; @@ -10,19 +8,6 @@ namespace Testably.Abstractions.Tests.FileSystem.FileSystemWatcher; [FileSystemTests] public partial class MoveTests { - private bool? _isMac; - - private bool IsMac - { - get - { - _isMac ??= this is RealFileSystemTests - && RuntimeInformation.IsOSPlatform(OSPlatform.OSX); - - return _isMac.Value; - } - } - [Theory] [InlineData(true)] [InlineData(false)] @@ -89,10 +74,11 @@ public async Task MoveToOutside_ShouldInvokeDeleted( params string[] paths ) { + SkipIfBrittleTestsShouldBeSkipped(Test.RunsOnMac); + // Arrange bool shouldInvokeDeleted = includeSubdirectories || paths.Length == 0; - bool shouldInvokeCreated = IsMac && shouldInvokeDeleted; // short names, otherwise the path will be too long const string outsideDirectory = "outside"; @@ -120,8 +106,7 @@ out ConcurrentBag deletedBag ); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created, - out ConcurrentBag createdBag + fileSystemWatcher, WatcherChangeTypes.Created ); using ManualResetEventSlim renamedMs = AddRenamedEventHandler(fileSystemWatcher); @@ -138,13 +123,10 @@ out ConcurrentBag createdBag await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) .IsEqualTo(shouldInvokeDeleted); - await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) - .IsEqualTo(shouldInvokeCreated); + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - await RemoveMacArrangeEvents(createdBag, insideTarget, insideDirectory, insideTarget); - await ThatIsSingleOrEmpty(deletedBag, !shouldInvokeDeleted); if (shouldInvokeDeleted) @@ -172,6 +154,8 @@ public async Task MoveToSameDirectory_ShouldInvokeRenamed( params string[] paths ) { + SkipIfBrittleTestsShouldBeSkipped(Test.RunsOnMac); + // Arrange bool shouldInvokeRenamed = includeSubdirectories || paths.Length == 0; @@ -208,8 +192,7 @@ params string[] paths ); using ManualResetEventSlim createdMs = AddEventHandler( - fileSystemWatcher, WatcherChangeTypes.Created, - out ConcurrentBag createdBag + fileSystemWatcher, WatcherChangeTypes.Created ); fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; @@ -226,10 +209,7 @@ await That(renamedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) await That(deletedMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); - await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)) - .IsEqualTo(IsMac && shouldInvokeRenamed); - - await RemoveMacArrangeEvents(createdBag, string.Empty /*None expected*/, insideTarget); + await That(createdMs.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsFalse(); await ThatIsSingleOrEmpty(renamedBag, !shouldInvokeRenamed); @@ -261,51 +241,6 @@ private static async Task ThatIsSingleOrEmpty(IEnumerable value, bool expe } } - private async Task RemoveMacArrangeEvents( - ConcurrentBag createdBag, - string expectedFullPath, - params string[] initialDirectories - ) - { - if (!IsMac) - { - return; - } - - FileSystemEventArgs? expectedEvent = null; - - while (createdBag.TryTake(out FileSystemEventArgs? createdEvent)) - { - if (createdEvent.ChangeType == WatcherChangeTypes.Created - && string.Equals(createdEvent.FullPath, expectedFullPath, StringComparison.Ordinal)) - { - expectedEvent = createdEvent; - - continue; - } - - await That(createdEvent) - .Satisfies(x => initialDirectories.Any(directory => string.Equals( - directory, x.FullPath, - StringComparison.Ordinal - ) - ) - ).Because( - nameof(createdEvent.FullPath) - + " should be one of the initial directories: [" - + string.Join(", ", initialDirectories) - + "]" - ); - } - - await That(createdBag).IsEmpty(); - - if (expectedEvent is not null) - { - createdBag.Add(expectedEvent); - } - } - private static ManualResetEventSlim AddEventHandler( IFileSystemWatcher fileSystemWatcher, WatcherChangeTypes changeType