diff --git a/Source/Testably.Abstractions.Testing/FileSystem/DriveInfoFactoryMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/DriveInfoFactoryMock.cs index 0c3574272..762b4823d 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/DriveInfoFactoryMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/DriveInfoFactoryMock.cs @@ -30,6 +30,7 @@ public IDriveInfo[] GetDrives() return _fileSystem.Storage.GetDrives() .Where(x => !x.IsUncPath) .Cast() + .OrderBy(x => x.Name) .ToArray(); } diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs new file mode 100644 index 000000000..a4e493f93 --- /dev/null +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs @@ -0,0 +1,22 @@ +using System.IO; +#if FEATURE_SPAN +using System; +#endif + +namespace Testably.Abstractions.Testing.Helpers; + +internal partial class Execute +{ + private class LinuxPath(MockFileSystem fileSystem) : NativePath(fileSystem) + { +#if FEATURE_SPAN + /// + public override bool IsPathRooted(ReadOnlySpan path) + => IsPathRooted(path.ToString()); +#endif + + /// + public override bool IsPathRooted(string? path) + => path?.Length > 0 && path[0] == '/'; + } +} diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.MacPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.MacPath.cs new file mode 100644 index 000000000..1bca999cb --- /dev/null +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.MacPath.cs @@ -0,0 +1,12 @@ +#if FEATURE_SPAN +#endif +#if FEATURE_FILESYSTEM_NET7 +using Testably.Abstractions.Testing.Storage; +#endif + +namespace Testably.Abstractions.Testing.Helpers; + +internal partial class Execute +{ + private sealed class MacPath(MockFileSystem fileSystem) : LinuxPath(fileSystem); +} diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.NativePath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.NativePath.cs index 8aac87671..3ff65d12b 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.NativePath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.NativePath.cs @@ -11,15 +11,8 @@ namespace Testably.Abstractions.Testing.Helpers; internal partial class Execute { - private sealed class NativePath : IPath + private class NativePath(MockFileSystem fileSystem) : IPath { - private readonly MockFileSystem _fileSystem; - - public NativePath(MockFileSystem fileSystem) - { - _fileSystem = fileSystem; - } - #region IPath Members /// @@ -31,7 +24,7 @@ public char DirectorySeparatorChar => System.IO.Path.DirectorySeparatorChar; /// - public IFileSystem FileSystem => _fileSystem; + public IFileSystem FileSystem => fileSystem; /// public char PathSeparator @@ -83,7 +76,7 @@ public bool Exists([NotNullWhen(true)] string? path) return false; } - return _fileSystem.Storage.GetContainer(_fileSystem.Storage.GetLocation(path)) + return fileSystem.Storage.GetContainer(fileSystem.Storage.GetLocation(path)) is not NullContainer; } #endif @@ -134,11 +127,11 @@ public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) /// public string GetFullPath(string path) { - path.EnsureValidArgument(_fileSystem, nameof(path)); + path.EnsureValidArgument(fileSystem, nameof(path)); string? pathRoot = System.IO.Path.GetPathRoot(path); string? directoryRoot = - System.IO.Path.GetPathRoot(_fileSystem.Storage.CurrentDirectory); + System.IO.Path.GetPathRoot(fileSystem.Storage.CurrentDirectory); if (!string.IsNullOrEmpty(pathRoot) && !string.IsNullOrEmpty(directoryRoot)) { if (char.ToUpperInvariant(pathRoot[0]) != char.ToUpperInvariant(directoryRoot[0])) @@ -153,7 +146,7 @@ public string GetFullPath(string path) } return System.IO.Path.GetFullPath(System.IO.Path.Combine( - _fileSystem.Storage.CurrentDirectory, + fileSystem.Storage.CurrentDirectory, path)); } @@ -189,11 +182,11 @@ public string GetRandomFileName() /// public string GetRelativePath(string relativeTo, string path) { - relativeTo.EnsureValidArgument(_fileSystem, nameof(relativeTo)); - path.EnsureValidArgument(_fileSystem, nameof(path)); + relativeTo.EnsureValidArgument(fileSystem, nameof(relativeTo)); + path.EnsureValidArgument(fileSystem, nameof(path)); - relativeTo = _fileSystem.Execute.Path.GetFullPath(relativeTo); - path = _fileSystem.Execute.Path.GetFullPath(path); + relativeTo = fileSystem.Execute.Path.GetFullPath(relativeTo); + path = fileSystem.Execute.Path.GetFullPath(path); return System.IO.Path.GetRelativePath(relativeTo, path); } @@ -235,12 +228,12 @@ public bool IsPathFullyQualified(string path) #if FEATURE_SPAN /// - public bool IsPathRooted(ReadOnlySpan path) + public virtual bool IsPathRooted(ReadOnlySpan path) => System.IO.Path.IsPathRooted(path); #endif /// - public bool IsPathRooted(string? path) + public virtual bool IsPathRooted(string? path) => System.IO.Path.IsPathRooted(path); #if FEATURE_PATH_JOIN diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs new file mode 100644 index 000000000..b59170bfb --- /dev/null +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs @@ -0,0 +1,40 @@ +using System.IO; +#if FEATURE_SPAN +using System; +#endif + +namespace Testably.Abstractions.Testing.Helpers; + +internal partial class Execute +{ + private sealed class WindowsPath(MockFileSystem fileSystem) : NativePath(fileSystem) + { +#if FEATURE_SPAN + /// + public override bool IsPathRooted(ReadOnlySpan path) + => IsPathRooted(path.ToString()); +#endif + + /// + public override bool IsPathRooted(string? path) + { + int? length = path?.Length; + return (length >= 1 && IsDirectorySeparator(path![0])) || + (length >= 2 && IsValidDriveChar(path![0]) && path[1] == VolumeSeparatorChar); + } + + /// + /// True if the given character is a directory separator. + /// + /// https://github.com/dotnet/runtime/blob/v8.0.3/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L280 + private static bool IsDirectorySeparator(char c) + => c == '\\' || c == '/'; + + /// + /// Returns true if the given character is a valid drive letter + /// + /// https://github.com/dotnet/runtime/blob/v8.0.3/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L72 + private static bool IsValidDriveChar(char value) + => (uint)((value | 0x20) - 'a') <= 'z' - 'a'; + } +} diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.cs index c9872e6bb..13961aaa1 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.cs @@ -47,7 +47,23 @@ internal Execute(MockFileSystem fileSystem, OSPlatform osPlatform, bool isNetFra StringComparisonMode = IsLinux ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; - Path = new NativePath(fileSystem); + if (IsLinux) + { + Path = new LinuxPath(fileSystem); + } + else if (IsMac) + { + Path = new MacPath(fileSystem); + } + else if (IsWindows) + { + Path = new WindowsPath(fileSystem); + } + else + { + throw new NotSupportedException( + "The operating system must be one of Linux, OSX or Windows"); + } } internal Execute(MockFileSystem fileSystem) diff --git a/Source/Testably.Abstractions.Testing/MockFileSystem.cs b/Source/Testably.Abstractions.Testing/MockFileSystem.cs index 202e28d07..5603ac1f7 100644 --- a/Source/Testably.Abstractions.Testing/MockFileSystem.cs +++ b/Source/Testably.Abstractions.Testing/MockFileSystem.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; using Testably.Abstractions.Testing.FileSystem; using Testably.Abstractions.Testing.Helpers; using Testably.Abstractions.Testing.Statistics; @@ -82,9 +83,19 @@ internal IReadOnlyList StorageContainers /// /// Initializes the . /// - public MockFileSystem() + public MockFileSystem() : this(_ => { }) { } + + /// + /// Initializes the with the . + /// + internal MockFileSystem(Action initializationCallback) { - Execute = new Execute(this); + Initialization initialization = new(); + initializationCallback(initialization); + + Execute = initialization.OperatingSystem == null + ? new Execute(this) + : new Execute(this, initialization.OperatingSystem.Value); StatisticsRegistration = new FileSystemStatistics(this); using IDisposable release = StatisticsRegistration.Ignore(); RandomSystem = new MockRandomSystem(); @@ -101,7 +112,7 @@ public MockFileSystem() FileSystemWatcher = new FileSystemWatcherFactoryMock(this); SafeFileHandleStrategy = new NullSafeFileHandleStrategy(); AccessControlStrategy = new NullAccessControlStrategy(); - AddDriveFromCurrentDirectory(); + InitializeFileSystem(initialization); } #region IFileSystem Members @@ -181,11 +192,17 @@ public MockFileSystem WithSafeFileHandleStrategy( return this; } - private void AddDriveFromCurrentDirectory() + private void InitializeFileSystem(Initialization initialization) { try { - string? root = Path.GetPathRoot(System.IO.Directory.GetCurrentDirectory()); + if (initialization.CurrentDirectory != null) + { + IDirectoryInfo directoryInfo = DirectoryInfo.New(initialization.CurrentDirectory); + Storage.CurrentDirectory = directoryInfo.FullName; + } + + string? root = Execute.Path.GetPathRoot(Directory.GetCurrentDirectory()); if (root != null && root[0] != _storage.MainDrive.Name[0]) { @@ -198,4 +215,61 @@ private void AddDriveFromCurrentDirectory() // due to brittle tests on MacOS } } + + /// + /// The initialization options for the . + /// + internal class Initialization + { + /// + /// The current directory. + /// + internal string? CurrentDirectory { get; private set; } + + /// + /// The simulated operating system. + /// + internal OSPlatform? OperatingSystem { get; private set; } + + /// + /// Specify the operating system that should be simulated. + /// + /// + /// Supported values are
+ /// -
+ /// -
+ /// - + ///
+ internal Initialization SimulatingOperatingSystem(OSPlatform operatingSystem) + { + if (operatingSystem != OSPlatform.Linux && + operatingSystem != OSPlatform.OSX && + operatingSystem != OSPlatform.Windows) + { + throw new NotSupportedException( + "Only Linux, OSX and Windows are supported operating systems."); + } + + OperatingSystem = operatingSystem; + return this; + } + + /// + /// Use the provided as current directory. + /// + internal Initialization UseCurrentDirectory(string path) + { + CurrentDirectory = path; + return this; + } + + /// + /// Use as current directory. + /// + internal Initialization UseCurrentDirectory() + { + CurrentDirectory = System.IO.Directory.GetCurrentDirectory(); + return this; + } + } } diff --git a/Tests/Testably.Abstractions.Testing.Tests/Helpers/ExecuteTests.cs b/Tests/Testably.Abstractions.Testing.Tests/Helpers/ExecuteTests.cs index 186964215..09313c788 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/Helpers/ExecuteTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/Helpers/ExecuteTests.cs @@ -5,6 +5,23 @@ namespace Testably.Abstractions.Testing.Tests.Helpers; public sealed class ExecuteTests { +#if !NET48 + [Fact] + public void Constructor_ForFreeBSD_ShouldThrowNotSupportedException() + { + Exception? exception = Record.Exception(() => + { + _ = new Execute(new MockFileSystem(), OSPlatform.FreeBSD); + }); + + exception.Should().BeOfType() + .Which.Message.Should() + .Contain("Linux").And + .Contain("Windows").And + .Contain("OSX"); + } +#endif + [Fact] public void Constructor_ForLinux_ShouldInitializeAccordingly() { diff --git a/Tests/Testably.Abstractions.Testing.Tests/MockFileSystemInitializationTests.cs b/Tests/Testably.Abstractions.Testing.Tests/MockFileSystemInitializationTests.cs new file mode 100644 index 000000000..6b014b0be --- /dev/null +++ b/Tests/Testably.Abstractions.Testing.Tests/MockFileSystemInitializationTests.cs @@ -0,0 +1,147 @@ +using System.IO; +using System.Runtime.InteropServices; +using Testably.Abstractions.Testing.Tests.TestHelpers; +#if NET6_0_OR_GREATER +#endif + +namespace Testably.Abstractions.Testing.Tests; + +public class MockFileSystemInitializationTests +{ + [Fact] + public void MockFileSystem_WhenSimulatingLinux_ShouldBeLinux() + { + MockFileSystem sut = new(o => o + .SimulatingOperatingSystem(OSPlatform.Linux)); + + sut.Execute.IsLinux.Should().BeTrue(); + sut.Execute.IsMac.Should().BeFalse(); + sut.Execute.IsWindows.Should().BeFalse(); + sut.Execute.IsNetFramework.Should().BeFalse(); + } + + [Fact] + public void MockFileSystem_WhenSimulatingOSX_ShouldBeMac() + { + MockFileSystem sut = new(o => o + .SimulatingOperatingSystem(OSPlatform.OSX)); + + sut.Execute.IsLinux.Should().BeFalse(); + sut.Execute.IsMac.Should().BeTrue(); + sut.Execute.IsWindows.Should().BeFalse(); + sut.Execute.IsNetFramework.Should().BeFalse(); + } + + [SkippableFact] + public void MockFileSystem_WhenSimulatingWindows_ShouldBeWindows() + { + Skip.IfNot(Test.RunsOnWindows, + "TODO: Enable again, once the Path implementation is sufficiently complete!"); + + MockFileSystem sut = new(o => o + .SimulatingOperatingSystem(OSPlatform.Windows)); + + sut.Execute.IsLinux.Should().BeFalse(); + sut.Execute.IsMac.Should().BeFalse(); + sut.Execute.IsWindows.Should().BeTrue(); + sut.Execute.IsNetFramework.Should().BeFalse(); + } + + [Fact] + public void MockFileSystem_WithCurrentDirectory_ShouldInitializeCurrentDirectory() + { + string expected = Directory.GetCurrentDirectory(); + MockFileSystem sut = new(o => o.UseCurrentDirectory()); + + string result = sut.Directory.GetCurrentDirectory(); + + result.Should().Be(expected); + } + + [Theory] + [AutoData] + public void MockFileSystem_WithExplicitCurrentDirectory_ShouldInitializeCurrentDirectory( + string path) + { + string expected = Test.RunsOnWindows ? $"C:\\{path}" : $"/{path}"; + MockFileSystem sut = new(o => o.UseCurrentDirectory(path)); + + string result = sut.Directory.GetCurrentDirectory(); + + result.Should().Be(expected); + } + + [Fact] + public void MockFileSystem_WithoutCurrentDirectory_ShouldUseDefaultDriveAsCurrentDirectory() + { + string expected = Test.RunsOnWindows ? "C:\\" : "/"; + MockFileSystem sut = new(); + + string result = sut.Directory.GetCurrentDirectory(); + + result.Should().Be(expected); + } + +#if !NET48 + [Fact] + public void SimulatingOperatingSystem_FreeBSD_ShouldThrowNotSupportedException() + { + MockFileSystem.Initialization sut = new(); + + Exception? exception = Record.Exception(() => + { + sut.SimulatingOperatingSystem(OSPlatform.FreeBSD); + }); + + exception.Should().BeOfType() + .Which.Message.Should() + .Contain("Linux").And + .Contain("Windows").And + .Contain("OSX"); + } +#endif + + [Theory] + [MemberData(nameof(ValidOperatingSystems))] + public void SimulatingOperatingSystem_ValidOSPlatform_ShouldSetOperatingSystem( + OSPlatform osPlatform) + { + MockFileSystem.Initialization sut = new(); + + MockFileSystem.Initialization result = sut.SimulatingOperatingSystem(osPlatform); + + result.OperatingSystem.Should().Be(osPlatform); + sut.OperatingSystem.Should().Be(osPlatform); + } + + [Fact] + public void UseCurrentDirectory_Empty_ShouldUseCurrentDirectory() + { + string expected = Directory.GetCurrentDirectory(); + MockFileSystem.Initialization sut = new(); + + MockFileSystem.Initialization result = sut.UseCurrentDirectory(); + + result.CurrentDirectory.Should().Be(expected); + sut.CurrentDirectory.Should().Be(expected); + } + + [Theory] + [AutoData] + public void UseCurrentDirectory_WithPath_ShouldUsePathCurrentDirectory(string path) + { + MockFileSystem.Initialization sut = new(); + + MockFileSystem.Initialization result = sut.UseCurrentDirectory(path); + + result.CurrentDirectory.Should().Be(path); + sut.CurrentDirectory.Should().Be(path); + } + + #region Helpers + + public static TheoryData ValidOperatingSystems() + => new(OSPlatform.Linux, OSPlatform.OSX, OSPlatform.Windows); + + #endregion +} diff --git a/Tests/Testably.Abstractions.Testing.Tests/MockFileSystemTests.cs b/Tests/Testably.Abstractions.Testing.Tests/MockFileSystemTests.cs index dcd6e7850..1f891bdd3 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/MockFileSystemTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/MockFileSystemTests.cs @@ -92,17 +92,19 @@ public void FileSystemMock_ShouldBeInitializedWithADefaultDrive() drive.VolumeLabel.Should().NotBeNullOrEmpty(); } - [SkippableFact] - public void FileSystemMock_ShouldInitializeDriveFromCurrentDirectory() + [SkippableTheory] + [InlineData("A:\\")] + [InlineData("G:\\")] + [InlineData("z:\\")] + public void FileSystemMock_ShouldInitializeDriveFromCurrentDirectory(string driveName) { - string? driveName = Path.GetPathRoot(Directory.GetCurrentDirectory()); + Skip.If(!Test.RunsOnWindows); - Skip.If(!Test.RunsOnWindows || driveName?.StartsWith('C') != false); - - MockFileSystem sut = new(); + MockFileSystem sut = new(o => o.UseCurrentDirectory($"{driveName}foo\\bar")); IDriveInfo[] drives = sut.DriveInfo.GetDrives(); drives.Length.Should().Be(2); + drives.Should().Contain(d => d.Name == "C:\\"); drives.Should().Contain(d => d.Name == driveName); } diff --git a/Tests/Testably.Abstractions.Testing.Tests/Statistics/PathStatisticsTests.cs b/Tests/Testably.Abstractions.Testing.Tests/Statistics/PathStatisticsTests.cs index 3a4f368b7..1f7e4262b 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/Statistics/PathStatisticsTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/Statistics/PathStatisticsTests.cs @@ -56,7 +56,7 @@ public void Key_NullShouldBeSameAsEmptyKey() [Fact] public void Key_ShouldSimplifyRelativePaths() { - MockFileSystem fileSystem = new(); + MockFileSystem fileSystem = new(o => o.UseCurrentDirectory()); fileSystem.InitializeIn("/foo/bar"); IPathStatistics sut = fileSystem.Statistics.FileInfo;