From 2932ab17654bf4cc04de18aef1d0b878be34bf6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 20 Apr 2024 16:06:26 +0200 Subject: [PATCH 1/3] feat: implement `GetDirectoryName` for simulated `Path` (#571) Implement the `GetDirectoryName` methods for `Path`. --- .../Helpers/Execute.LinuxPath.cs | 61 +++++- .../Helpers/Execute.SimulatedPath.cs | 30 ++- .../Helpers/Execute.WindowsPath.cs | 176 ++++++++++++++++++ .../FileSystemClassGenerator.cs | 1 + .../FileSystem/Path/GetDirectoryNameTests.cs | 93 +++++++++ .../FileSystem/Path/IsPathRootedTests.cs | 12 +- .../TestHelpers/TestExtensions.cs | 2 +- 7 files changed, 366 insertions(+), 9 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs index 73aee07f8..42a38341c 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs @@ -1,4 +1,6 @@ -namespace Testably.Abstractions.Testing.Helpers; +using System.Text; + +namespace Testably.Abstractions.Testing.Helpers; internal partial class Execute { @@ -43,10 +45,67 @@ public override string GetTempPath() public override bool IsPathRooted(string? path) => path?.Length > 0 && path[0] == '/'; + /// + /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L22 + /// + protected override int GetRootLength(string path) + { + return path.Length > 0 && IsDirectorySeparator(path[0]) ? 1 : 0; + } + /// /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L27 /// protected override bool IsDirectorySeparator(char c) => c == DirectorySeparatorChar; + + /// + /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L89 + /// + protected override bool IsEffectivelyEmpty(string path) + => string.IsNullOrEmpty(path); + + /// + /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L39 + /// + protected override string NormalizeDirectorySeparators(string path) + { + bool IsAlreadyNormalized() + { + for (int i = 0; i < path.Length - 1; i++) + { + if (IsDirectorySeparator(path[i]) && + IsDirectorySeparator(path[i + 1])) + { + return false; + } + } + + return true; + } + + if (IsAlreadyNormalized()) + { + return path; + } + + StringBuilder builder = new(path.Length); + + for (int j = 0; j < path.Length - 1; j++) + { + char current = path[j]; + + if (IsDirectorySeparator(current) + && IsDirectorySeparator(path[j + 1])) + { + continue; + } + + builder.Append(current); + } + + builder.Append(path[path.Length - 1]); + return builder.ToString(); + } } } diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs index e556b852e..3c43a27e0 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs @@ -161,7 +161,31 @@ public ReadOnlySpan GetDirectoryName(ReadOnlySpan path) /// public string? GetDirectoryName(string? path) - => System.IO.Path.GetDirectoryName(path); + { + if (path == null || IsEffectivelyEmpty(path)) + { + return null; + } + + int rootLength = GetRootLength(path); + if (path.Length <= rootLength) + { + return null; + } + + int end = path.Length; + while (end > rootLength && !IsDirectorySeparator(path[end - 1])) + { + end--; + } + + while (end > rootLength && IsDirectorySeparator(path[end - 1])) + { + end--; + } + + return NormalizeDirectorySeparators(path.Substring(0, end)); + } #if FEATURE_SPAN /// @@ -447,7 +471,9 @@ public bool TryJoin(ReadOnlySpan path1, private static string CombineInternal(string[] paths) => System.IO.Path.Combine(paths); + protected abstract int GetRootLength(string path); protected abstract bool IsDirectorySeparator(char c); + protected abstract bool IsEffectivelyEmpty(string path); #if FEATURE_PATH_JOIN || FEATURE_PATH_ADVANCED private string JoinInternal(string?[] paths) @@ -489,6 +515,8 @@ private string JoinInternal(string?[] paths) } #endif + protected abstract string NormalizeDirectorySeparators(string path); + protected string RandomString(int length) { const string chars = "abcdefghijklmnopqrstuvwxyz0123456789"; diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs index eb0187083..17502e624 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs @@ -1,4 +1,6 @@ using System; +using System.Linq; +using System.Text; namespace Testably.Abstractions.Testing.Helpers; @@ -63,17 +65,191 @@ public override bool IsPathRooted(string? path) (length >= 2 && IsValidDriveChar(path![0]) && path[1] == VolumeSeparatorChar); } + /// + /// https://github.com/dotnet/runtime/blob/v8.0.3/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L181 + /// + protected override int GetRootLength(string path) + { + bool IsDeviceUNC(string p) + => p.Length >= 8 + && IsDevice(p) + && IsDirectorySeparator(p[7]) + && p[4] == 'U' + && p[5] == 'N' + && p[6] == 'C'; + + bool IsDevice(string p) + => IsExtended(p) + || + ( + p.Length >= 4 + && IsDirectorySeparator(p[0]) + && IsDirectorySeparator(p[1]) + && (p[2] == '.' || p[2] == '?') + && IsDirectorySeparator(p[3]) + ); + + bool IsExtended(string p) + => p.Length >= 4 + && p[0] == '\\' + && (p[1] == '\\' || p[1] == '?') + && p[2] == '?' + && p[3] == '\\'; + + int pathLength = path.Length; + + if (pathLength > 0 && IsDirectorySeparator(path[0])) + { + bool deviceSyntax = IsDevice(path); + bool deviceUnc = deviceSyntax && IsDeviceUNC(path); + + if (deviceSyntax && !deviceUnc) + { + return GetRootLengthWithDeviceSyntax(path); + } + + // UNC or simple rooted path (e.g. "\foo", NOT "\\?\C:\foo") + if (deviceUnc || (path.Length > 1 && IsDirectorySeparator(path[1]))) + { + return GetRootLengthWithDeviceUncSyntax(path, deviceUnc); + } + + // Current drive rooted (e.g. "\foo") + return 1; + } + + if (pathLength >= 2 + && path[1] == ':' + && IsValidDriveChar(path[0])) + { + // If the colon is followed by a directory separator, move past it (e.g "C:\") + if (pathLength > 2 && IsDirectorySeparator(path[2])) + { + return 3; + } + + // Valid drive specified path ("C:", "D:", etc.) + return 2; + } + + return 0; + } + + private int GetRootLengthWithDeviceSyntax(string path) + { + // Device path (e.g. "\\?\.", "\\.\") + // Skip any characters following the prefix that aren't a separator + int i = 4; + while (i < path.Length && !IsDirectorySeparator(path[i])) + { + i++; + } + + // If there is another separator take it, as long as we have had at least one + // non-separator after the prefix (e.g. don't take "\\?\\", but take "\\?\a\") + if (i < path.Length && i > 4 && IsDirectorySeparator(path[i])) + { + i++; + } + + return i; + } + + private int GetRootLengthWithDeviceUncSyntax(string path, + bool deviceUnc) + { + // Start past the prefix ("\\" or "\\?\UNC\") + int i = deviceUnc ? 8 : 2; + + // Skip two separators at most + int n = 2; + while (i < path.Length && (!IsDirectorySeparator(path[i]) || --n > 0)) + { + i++; + } + + return i; + } + /// /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L280 /// protected override bool IsDirectorySeparator(char c) => c == DirectorySeparatorChar || c == AltDirectorySeparatorChar; + /// + /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L381 + /// + protected override bool IsEffectivelyEmpty(string path) + { + if (string.IsNullOrEmpty(path)) + { + return true; + } + + return path.All(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'; + + /// + /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L318 + /// + protected override string NormalizeDirectorySeparators(string path) + { + bool IsAlreadyNormalized() + { + for (int i = 1; i < path.Length; i++) + { + char current = path[i]; + if (IsDirectorySeparator(current) + && (current != DirectorySeparatorChar + || (i + 1 < path.Length && IsDirectorySeparator(path[i + 1])))) + { + return false; + } + } + + return true; + } + + if (IsAlreadyNormalized()) + { + return path; + } + + StringBuilder builder = new(); + + int start = 0; + if (IsDirectorySeparator(path[start])) + { + start++; + builder.Append(DirectorySeparatorChar); + } + + for (int i = start; i < path.Length; i++) + { + char current = path[i]; + + if (IsDirectorySeparator(current)) + { + if (i + 1 < path.Length && IsDirectorySeparator(path[i + 1])) + { + continue; + } + + current = DirectorySeparatorChar; + } + + builder.Append(current); + } + + return builder.ToString(); + } } } diff --git a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs index adfe34e6c..65e155895 100644 --- a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs +++ b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs @@ -233,6 +233,7 @@ private bool IncludeSimulatedTests(ClassModel @class) [ "ChangeExtensionTests", "EndsInDirectorySeparatorTests", + "GetDirectoryNameTests", "GetExtensionTests", "GetFileNameTests", "GetFileNameWithoutExtensionTests", diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetDirectoryNameTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetDirectoryNameTests.cs index e15b233ac..1a869cba2 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetDirectoryNameTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetDirectoryNameTests.cs @@ -58,6 +58,8 @@ public void GetDirectoryName_Spaces_ShouldReturnNullOnWindowsOtherwiseEmpty(stri [SkippableTheory] [InlineData("\t")] [InlineData("\n")] + [InlineData(" \t")] + [InlineData("\n ")] public void GetDirectoryName_TabOrNewline_ShouldReturnEmptyString(string? path) { string? result = FileSystem.Path.GetDirectoryName(path); @@ -79,6 +81,37 @@ public void GetDirectoryName_ShouldReturnDirectory( result.Should().Be(directory); } + [SkippableTheory] + [AutoData] + public void GetDirectoryName_ShouldReplaceAltDirectorySeparator( + string parentDirectory, string directory, string filename) + { + string path = parentDirectory + FileSystem.Path.AltDirectorySeparatorChar + directory + + FileSystem.Path.AltDirectorySeparatorChar + filename; + string expected = parentDirectory + FileSystem.Path.DirectorySeparatorChar + directory; + + string? result = FileSystem.Path.GetDirectoryName(path); + + result.Should().Be(expected); + } + + [SkippableTheory] + [InlineData("foo//bar/file", "foo/bar", TestOS.All)] + [InlineData("foo///bar/file", "foo/bar", TestOS.All)] + [InlineData(@"foo\\bar/file", "foo/bar", TestOS.Windows)] + [InlineData(@"foo\\\bar/file", "foo/bar", TestOS.Windows)] + public void GetDirectoryName_ShouldNormalizeDirectorySeparators( + string path, string expected, TestOS operatingSystem) + { + Skip.IfNot(Test.RunsOn(operatingSystem)); + + expected = expected.Replace('/', FileSystem.Path.DirectorySeparatorChar); + + string? result = FileSystem.Path.GetDirectoryName(path); + + result.Should().Be(expected); + } + #if FEATURE_SPAN [SkippableTheory] [AutoData] @@ -93,4 +126,64 @@ public void GetDirectoryName_Span_ShouldReturnDirectory( result.ToString().Should().Be(directory); } #endif + + [SkippableTheory] + [InlineData("//", null, TestOS.Windows)] + [InlineData(@"\\", null, TestOS.Windows)] + [InlineData(@"\\", "", TestOS.Linux | TestOS.Mac)] + [InlineData(@"\", "", TestOS.Linux | TestOS.Mac)] + [InlineData(@"/", null, TestOS.Linux | TestOS.Mac)] + [InlineData(@"/a", "/", TestOS.Linux | TestOS.Mac)] + [InlineData(@"/a\b", @"/", TestOS.Linux | TestOS.Mac)] + [InlineData(@"/a\b/c", @"/a\b", TestOS.Linux | TestOS.Mac)] + [InlineData(@"/a/b/c", @"/a/b", TestOS.Linux | TestOS.Mac)] + [InlineData(@"/a/b", "/a", TestOS.Linux | TestOS.Mac)] + [InlineData("//?/G:/", null, TestOS.Windows)] + [InlineData("/??/H:/", @"\??\H:", TestOS.Windows)] + [InlineData("//?/I:/a", @"\\?\I:\", TestOS.Windows)] + [InlineData("/??/J:/a", @"\??\J:", TestOS.Windows)] + [InlineData(@"\\?\K:\", null, TestOS.Windows)] + [InlineData(@"\??\L:\", null, TestOS.Windows)] + [InlineData(@"\\?\M:\a", @"\\?\M:\", TestOS.Windows)] + [InlineData(@"\??\N:\a", @"\??\N:\", TestOS.Windows)] + [InlineData(@"\\?\UNC\", null, TestOS.Windows)] + [InlineData(@"//?/UNC/", null, TestOS.Windows)] + [InlineData(@"\??\UNC\", null, TestOS.Windows)] + [InlineData(@"/??/UNC/", @"\??\UNC", TestOS.Windows)] + [InlineData(@"\\?\UNC\a", null, TestOS.Windows)] + [InlineData(@"//?/UNC/a", null, TestOS.Windows)] + [InlineData(@"\??\UNC\a", null, TestOS.Windows)] + [InlineData(@"/??/UNC/a", @"\??\UNC", TestOS.Windows)] + [InlineData(@"\\?\ABC\", null, TestOS.Windows)] + [InlineData(@"//?/ABC/", null, TestOS.Windows)] + [InlineData(@"\??\XYZ\", null, TestOS.Windows)] + [InlineData(@"/??/XYZ/", @"\??\XYZ", TestOS.Windows)] + [InlineData(@"\\?\unc\a", @"\\?\unc\", TestOS.Windows)] + [InlineData(@"//?/unc/a", @"\\?\unc\", TestOS.Windows)] + [InlineData(@"\??\unc\a", @"\??\unc\", TestOS.Windows)] + [InlineData(@"/??/unc/a", @"\??\unc", TestOS.Windows)] + [InlineData("//./", null, TestOS.Windows)] + [InlineData(@"\\.\", null, TestOS.Windows)] + [InlineData("//?/", null, TestOS.Windows)] + [InlineData(@"\\?\", null, TestOS.Windows)] + [InlineData("//a/", null, TestOS.Windows)] + [InlineData(@"\\a\", null, TestOS.Windows)] + [InlineData(@"C:", null, TestOS.Windows)] + [InlineData(@"D:\", null, TestOS.Windows)] + [InlineData(@"E:/", null, TestOS.Windows)] + [InlineData(@"F:\a", @"F:\", TestOS.Windows)] + [InlineData(@"F:\b\c", @"F:\b", TestOS.Windows)] + [InlineData(@"F:\d/e", @"F:\d", TestOS.Windows)] + [InlineData(@"G:/f", @"G:\", TestOS.Windows)] + [InlineData(@"F:/g\h", @"F:\g", TestOS.Windows)] + [InlineData(@"G:/i/j", @"G:\i", TestOS.Windows)] + public void GetDirectoryName_SpecialCases_ShouldReturnExpectedValue( + string path, string? expected, TestOS operatingSystem) + { + Skip.IfNot(Test.RunsOn(operatingSystem)); + + string? result = FileSystem.Path.GetDirectoryName(path); + + result.Should().Be(expected); + } } diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/Path/IsPathRootedTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/Path/IsPathRootedTests.cs index facaf6542..d70701eb8 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/Path/IsPathRootedTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/Path/IsPathRootedTests.cs @@ -38,28 +38,28 @@ public static TheoryData TestData() "/", TestOS.All }, { - @"\", TestOS.Windows + @"\", TestOS.Windows | TestOS.Framework }, { "/foo", TestOS.All }, { - @"\foo", TestOS.Windows + @"\foo", TestOS.Windows | TestOS.Framework }, { "foo/bar", TestOS.None }, { - "a:", TestOS.Windows + "a:", TestOS.Windows | TestOS.Framework }, { - "z:", TestOS.Windows + "z:", TestOS.Windows | TestOS.Framework }, { - "A:", TestOS.Windows + "A:", TestOS.Windows | TestOS.Framework }, { - "Z:", TestOS.Windows + "Z:", TestOS.Windows | TestOS.Framework }, { "@:", TestOS.Framework diff --git a/Tests/Testably.Abstractions.Tests/TestHelpers/TestExtensions.cs b/Tests/Testably.Abstractions.Tests/TestHelpers/TestExtensions.cs index e31a3971b..be04d276a 100644 --- a/Tests/Testably.Abstractions.Tests/TestHelpers/TestExtensions.cs +++ b/Tests/Testably.Abstractions.Tests/TestHelpers/TestExtensions.cs @@ -20,6 +20,6 @@ public static T DependsOnOS(this Test test, T windows, T macOS, T linux) public static bool RunsOn(this Test test, TestOS operatingSystem) => (operatingSystem.HasFlag(TestOS.Linux) && test.RunsOnLinux) || (operatingSystem.HasFlag(TestOS.Mac) && test.RunsOnMac) || - (operatingSystem.HasFlag(TestOS.Windows) && test.RunsOnWindows) || + (operatingSystem.HasFlag(TestOS.Windows) && test is { RunsOnWindows: true, IsNetFramework: false }) || (operatingSystem.HasFlag(TestOS.Framework) && test.IsNetFramework); } From 760b601396b0ee98b975d11d493b268df1bf2033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 20 Apr 2024 16:50:45 +0200 Subject: [PATCH 2/3] feat: implement `Combine` for simulated `Path` (#572) Implement the `Combine` methods for `Path`. --- .../Helpers/Execute.SimulatedPath.cs | 62 +++++++++++++- .../FileSystemClassGenerator.cs | 1 + .../FileSystem/Path/CombineTests.cs | 85 +++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs index 3c43a27e0..a21c282b3 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs @@ -468,8 +468,66 @@ public bool TryJoin(ReadOnlySpan path1, #endregion - private static string CombineInternal(string[] paths) - => System.IO.Path.Combine(paths); + private string CombineInternal(string[] paths) + { + string NormalizePath(string path, bool ignoreStartingSeparator) + { + if (!ignoreStartingSeparator && ( + path[0] == DirectorySeparatorChar || + path[0] == AltDirectorySeparatorChar)) + { + path = path.Substring(1); + } + + if (path[path.Length - 1] == DirectorySeparatorChar || + path[path.Length - 1] == AltDirectorySeparatorChar) + { + path = path.Substring(0, path.Length - 1); + } + + return NormalizeDirectorySeparators(path); + } + + if (paths == null) + { + throw new ArgumentNullException(nameof(paths)); + } + + StringBuilder sb = new(); + + bool isFirst = true; + bool endsWithDirectorySeparator = false; + foreach (string path in paths) + { + if (path == null) + { + throw new ArgumentNullException(nameof(paths)); + } + + if (string.IsNullOrEmpty(path)) + { + continue; + } + + if (IsPathRooted(path)) + { + sb.Clear(); + isFirst = true; + } + + sb.Append(NormalizePath(path, isFirst)); + sb.Append(DirectorySeparatorChar); + endsWithDirectorySeparator = path.EndsWith(DirectorySeparatorChar) || + path.EndsWith(AltDirectorySeparatorChar); + } + + if (!endsWithDirectorySeparator) + { + return sb.ToString(0, sb.Length - 1); + } + + return sb.ToString(); + } protected abstract int GetRootLength(string path); protected abstract bool IsDirectorySeparator(char c); diff --git a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs index 65e155895..13d82cabd 100644 --- a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs +++ b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs @@ -232,6 +232,7 @@ private bool IncludeSimulatedTests(ClassModel @class) string[] supportedPathTests = [ "ChangeExtensionTests", + "CombineTests", "EndsInDirectorySeparatorTests", "GetDirectoryNameTests", "GetExtensionTests", diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/Path/CombineTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/Path/CombineTests.cs index 27c4771cb..d6dfea500 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/Path/CombineTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/Path/CombineTests.cs @@ -45,6 +45,25 @@ public void Combine_2Paths_Rooted_ShouldReturnLastRootedPath( result.Should().Be(path2); } + [SkippableTheory] + [InlineAutoData("/foo/", "/bar/", "/bar/")] + [InlineAutoData("foo/", "/bar", "/bar")] + [InlineAutoData("foo/", "bar", "foo/bar")] + [InlineAutoData("foo", "/bar", "/bar")] + [InlineAutoData("foo", "bar", "foo/bar")] + [InlineAutoData("/foo", "bar/", "/foo/bar/")] + public void Combine_2Paths_ShouldReturnExpectedResult( + string path1, string path2, string expectedResult) + { + path1 = path1.Replace('/', FileSystem.Path.DirectorySeparatorChar); + path2 = path2.Replace('/', FileSystem.Path.DirectorySeparatorChar); + expectedResult = expectedResult.Replace('/', FileSystem.Path.DirectorySeparatorChar); + + string result = FileSystem.Path.Combine(path1, path2); + + result.Should().Be(expectedResult); + } + [SkippableTheory] [InlineAutoData] [InlineAutoData(" ")] @@ -109,6 +128,27 @@ public void Combine_3Paths_Rooted_ShouldReturnLastRootedPath( result.Should().Be(path3); } + [SkippableTheory] + [InlineAutoData("/foo/", "/bar/", "/baz/", "/baz/")] + [InlineAutoData("foo/", "/bar/", "/baz", "/baz")] + [InlineAutoData("foo/", "bar", "/baz", "/baz")] + [InlineAutoData("foo", "/bar", "/baz", "/baz")] + [InlineAutoData("foo", "/bar/", "baz", "/bar/baz")] + [InlineAutoData("foo", "bar", "baz", "foo/bar/baz")] + [InlineAutoData("/foo", "bar", "baz/", "/foo/bar/baz/")] + public void Combine_3Paths_ShouldReturnExpectedResult( + string path1, string path2, string path3, string expectedResult) + { + path1 = path1.Replace('/', FileSystem.Path.DirectorySeparatorChar); + path2 = path2.Replace('/', FileSystem.Path.DirectorySeparatorChar); + path3 = path3.Replace('/', FileSystem.Path.DirectorySeparatorChar); + expectedResult = expectedResult.Replace('/', FileSystem.Path.DirectorySeparatorChar); + + string result = FileSystem.Path.Combine(path1, path2, path3); + + result.Should().Be(expectedResult); + } + [SkippableTheory] [InlineAutoData] [InlineAutoData(" ")] @@ -183,6 +223,28 @@ public void Combine_4Paths_Rooted_ShouldReturnLastRootedPath( result.Should().Be(path4); } + [SkippableTheory] + [InlineAutoData("/foo/", "/bar/", "/baz/", "/muh/", "/muh/")] + [InlineAutoData("foo/", "/bar/", "/baz/", "/muh", "/muh")] + [InlineAutoData("foo/", "bar", "/baz", "/muh", "/muh")] + [InlineAutoData("foo", "/bar", "/baz", "/muh", "/muh")] + [InlineAutoData("foo", "/bar/", "baz/", "muh", "/bar/baz/muh")] + [InlineAutoData("foo", "bar", "baz", "muh", "foo/bar/baz/muh")] + [InlineAutoData("/foo", "bar", "baz", "muh/", "/foo/bar/baz/muh/")] + public void Combine_4Paths_ShouldReturnExpectedResult( + string path1, string path2, string path3, string path4, string expectedResult) + { + path1 = path1.Replace('/', FileSystem.Path.DirectorySeparatorChar); + path2 = path2.Replace('/', FileSystem.Path.DirectorySeparatorChar); + path3 = path3.Replace('/', FileSystem.Path.DirectorySeparatorChar); + path4 = path4.Replace('/', FileSystem.Path.DirectorySeparatorChar); + expectedResult = expectedResult.Replace('/', FileSystem.Path.DirectorySeparatorChar); + + string result = FileSystem.Path.Combine(path1, path2, path3, path4); + + result.Should().Be(expectedResult); + } + [SkippableTheory] [InlineAutoData] [InlineAutoData(" ")] @@ -281,6 +343,29 @@ public void Combine_ParamPaths_Rooted_ShouldReturnLastRootedPath( result.Should().Be(path5); } + [SkippableTheory] + [InlineAutoData("/foo/", "/bar/", "/baz/", "/muh/", "/maeh/", "/maeh/")] + [InlineAutoData("foo/", "/bar/", "/baz/", "/muh", "/maeh", "/maeh")] + [InlineAutoData("foo/", "bar", "/baz", "/muh", "/maeh", "/maeh")] + [InlineAutoData("foo", "/bar", "/baz", "/muh", "/maeh", "/maeh")] + [InlineAutoData("foo", "/bar/", "baz/", "muh/", "maeh", "/bar/baz/muh/maeh")] + [InlineAutoData("foo", "bar", "baz", "muh", "maeh", "foo/bar/baz/muh/maeh")] + [InlineAutoData("/foo", "bar", "baz", "muh", "maeh/", "/foo/bar/baz/muh/maeh/")] + public void Combine_ParamPaths_ShouldReturnExpectedResult( + string path1, string path2, string path3, string path4, string path5, string expectedResult) + { + path1 = path1.Replace('/', FileSystem.Path.DirectorySeparatorChar); + path2 = path2.Replace('/', FileSystem.Path.DirectorySeparatorChar); + path3 = path3.Replace('/', FileSystem.Path.DirectorySeparatorChar); + path4 = path4.Replace('/', FileSystem.Path.DirectorySeparatorChar); + path5 = path5.Replace('/', FileSystem.Path.DirectorySeparatorChar); + expectedResult = expectedResult.Replace('/', FileSystem.Path.DirectorySeparatorChar); + + string result = FileSystem.Path.Combine(path1, path2, path3, path4, path5); + + result.Should().Be(expectedResult); + } + [SkippableTheory] [InlineAutoData] [InlineAutoData(" ")] From c9d4256858dd1dfc6dd01aece7092fd713b83bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 20 Apr 2024 23:32:56 +0200 Subject: [PATCH 3/3] feat: implement `GetFullPath` for simulated `Path` (#573) * Add GetFullPath * Skip failing simulated test on .Net Framework * Initialize simulated tests --- .../Helpers/ExceptionFactory.cs | 15 ++ .../Helpers/Execute.LinuxPath.cs | 57 ++++++- .../Helpers/Execute.SimulatedPath.cs | 144 ++++++++++++++---- .../Helpers/Execute.WindowsPath.cs | 110 ++++++++++++- .../FileSystemClassGenerator.cs | 44 +++++- .../Helpers/PathHelperTests.cs | 2 + .../FileSystem/Path/GetPathRootTests.cs | 12 ++ 7 files changed, 349 insertions(+), 35 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs index 2744173aa..74a9acfef 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs @@ -29,6 +29,14 @@ internal static ArgumentException AppendAccessOnlyInWriteOnlyMode( #endif }; + internal static ArgumentException BasePathNotFullyQualified(string paramName) + => new("Basepath argument is not fully qualified.", paramName) + { +#if FEATURE_EXCEPTION_HRESULT + HResult = -2147024809 +#endif + }; + internal static IOException CannotCreateFileAsAlreadyExists(Execute execute, string path) => new( $"Cannot create '{path}' because a file or directory with the same name already exists.", @@ -127,6 +135,13 @@ internal static NotSupportedException NotSupportedSafeFileHandle() internal static NotSupportedException NotSupportedTimerWrapping() => new("You cannot wrap an existing Timer in the MockTimeSystem instance!"); + internal static ArgumentException NullCharacterInPath(string paramName) +#if NET8_0_OR_GREATER + => new("Null character in path.", paramName); +#else + => new("Illegal characters in path.", paramName); +#endif + internal static PlatformNotSupportedException OperationNotSupportedOnThisPlatform() => new("Operation is not supported on this platform.") { diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs index 42a38341c..203f4c515 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs @@ -1,4 +1,5 @@ -using System.Text; +using System; +using System.Text; namespace Testably.Abstractions.Testing.Helpers; @@ -18,6 +19,54 @@ private class LinuxPath(MockFileSystem fileSystem) : SimulatedPath(fileSystem) /// public override char VolumeSeparatorChar => '/'; + private readonly MockFileSystem _fileSystem = fileSystem; + + /// + public override string GetFullPath(string path) + { + path.EnsureValidArgument(_fileSystem, nameof(path)); + + if (!IsPathRooted(path)) + { + path = Combine(_fileSystem.Storage.CurrentDirectory, path); + } + + // We would ideally use realpath to do this, but it resolves symlinks and requires that the file actually exist. + string collapsedString = RemoveRelativeSegments(path, GetRootLength(path)); + + string result = collapsedString.Length == 0 + ? $"{DirectorySeparatorChar}" + : collapsedString; + + if (result.Contains('\0', StringComparison.Ordinal)) + { + throw ExceptionFactory.NullCharacterInPath(nameof(path)); + } + + return result; + } + +#if FEATURE_PATH_RELATIVE + /// + public override string GetFullPath(string path, string basePath) + { + path.EnsureValidArgument(_fileSystem, nameof(path)); + basePath.EnsureValidArgument(_fileSystem, nameof(basePath)); + + if (!IsPathFullyQualified(basePath)) + { + throw ExceptionFactory.BasePathNotFullyQualified(nameof(basePath)); + } + + if (IsPathFullyQualified(path)) + { + return GetFullPath(path); + } + + return GetFullPath(Combine(basePath, path)); + } +#endif + /// public override char[] GetInvalidFileNameChars() => ['\0', '/']; @@ -65,6 +114,12 @@ protected override bool IsDirectorySeparator(char c) protected override bool IsEffectivelyEmpty(string path) => string.IsNullOrEmpty(path); + /// + /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L77 + /// + protected override bool IsPartiallyQualified(string path) + => !IsPathRooted(path); + /// /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L39 /// diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs index a21c282b3..cca4ad123 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; @@ -259,35 +260,11 @@ public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) } /// - public string GetFullPath(string path) - { - path.EnsureValidArgument(fileSystem, nameof(path)); - - string? pathRoot = System.IO.Path.GetPathRoot(path); - string? directoryRoot = - System.IO.Path.GetPathRoot(fileSystem.Storage.CurrentDirectory); - if (!string.IsNullOrEmpty(pathRoot) && !string.IsNullOrEmpty(directoryRoot)) - { - if (char.ToUpperInvariant(pathRoot[0]) != char.ToUpperInvariant(directoryRoot[0])) - { - return System.IO.Path.GetFullPath(path); - } - - if (pathRoot.Length < directoryRoot.Length) - { - path = path.Substring(pathRoot.Length); - } - } - - return System.IO.Path.GetFullPath(System.IO.Path.Combine( - fileSystem.Storage.CurrentDirectory, - path)); - } + public abstract string GetFullPath(string path); #if FEATURE_PATH_RELATIVE /// - public string GetFullPath(string path, string basePath) - => System.IO.Path.GetFullPath(path, basePath); + public abstract string GetFullPath(string path, string basePath); #endif /// @@ -353,7 +330,14 @@ public bool IsPathFullyQualified(ReadOnlySpan path) #if FEATURE_PATH_RELATIVE /// public bool IsPathFullyQualified(string path) - => System.IO.Path.IsPathFullyQualified(path); + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + return !IsPartiallyQualified(path); + } #endif #if FEATURE_SPAN @@ -533,6 +517,8 @@ string NormalizePath(string path, bool ignoreStartingSeparator) protected abstract bool IsDirectorySeparator(char c); protected abstract bool IsEffectivelyEmpty(string path); + protected abstract bool IsPartiallyQualified(string path); + #if FEATURE_PATH_JOIN || FEATURE_PATH_ADVANCED private string JoinInternal(string?[] paths) { @@ -582,6 +568,110 @@ protected string RandomString(int length) .Select(s => s[fileSystem.RandomSystem.Random.Shared.Next(s.Length)]).ToArray()); } + /// + /// Remove relative segments from the given path (without combining with a root). + /// + protected string RemoveRelativeSegments(string path, int rootLength) + { + Debug.Assert(rootLength > 0); + bool flippedSeparator = false; + + StringBuilder sb = new(); + + int skip = rootLength; + // We treat "\.." , "\." and "\\" as a relative segment. We want to collapse the first separator past the root presuming + // the root actually ends in a separator. Otherwise the first segment for RemoveRelativeSegments + // in cases like "\\?\C:\.\" and "\\?\C:\..\", the first segment after the root will be ".\" and "..\" which is not considered as a relative segment and hence not be removed. + if (IsDirectorySeparator(path[skip - 1])) + { + skip--; + } + + // Remove "//", "/./", and "/../" from the path by copying each character to the output, + // except the ones we're removing, such that the builder contains the normalized path + // at the end. + if (skip > 0) + { + sb.Append(path.Substring(0, skip)); + } + + for (int i = skip; i < path.Length; i++) + { + char c = path[i]; + + if (IsDirectorySeparator(c) && i + 1 < path.Length) + { + // Skip this character if it's a directory separator and if the next character is, too, + // e.g. "parent//child" => "parent/child" + if (IsDirectorySeparator(path[i + 1])) + { + continue; + } + + // Skip this character and the next if it's referring to the current directory, + // e.g. "parent/./child" => "parent/child" + if ((i + 2 == path.Length || IsDirectorySeparator(path[i + 2])) && + path[i + 1] == '.') + { + i++; + continue; + } + + // Skip this character and the next two if it's referring to the parent directory, + // e.g. "parent/child/../grandchild" => "parent/grandchild" + if (i + 2 < path.Length && + (i + 3 == path.Length || IsDirectorySeparator(path[i + 3])) && + path[i + 1] == '.' && path[i + 2] == '.') + { + // Unwind back to the last slash (and if there isn't one, clear out everything). + int s; + for (s = sb.Length - 1; s >= skip; s--) + { + if (IsDirectorySeparator(sb[s])) + { + sb.Length = + i + 3 >= path.Length && s == skip + ? s + 1 + : s; // to avoid removing the complete "\tmp\" segment in cases like \\?\C:\tmp\..\, C:\tmp\.. + break; + } + } + + if (s < skip) + { + sb.Length = skip; + } + + i += 2; + continue; + } + } + + // Normalize the directory separator if needed + if (c != DirectorySeparatorChar && c == AltDirectorySeparatorChar) + { + c = DirectorySeparatorChar; + flippedSeparator = true; + } + + sb.Append(c); + } + + // If we haven't changed the source path, return the original + if (!flippedSeparator && sb.Length == path.Length) + { + return path; + } + + // We may have eaten the trailing separator from the root when we started and not replaced it + if (skip != rootLength && sb.Length < rootLength) + { + sb.Append(path[rootLength - 1]); + } + + return sb.ToString(); + } + private bool TryGetExtensionIndex(string path, [NotNullWhen(true)] out int? dotIndex) { for (int i = path.Length - 1; i >= 0; i--) diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs index 17502e624..c42c0f03d 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs @@ -20,6 +20,85 @@ private sealed class WindowsPath(MockFileSystem fileSystem) : SimulatedPath(file /// public override char VolumeSeparatorChar => ':'; + private readonly MockFileSystem _fileSystem = fileSystem; + + /// + public override string GetFullPath(string path) + { + path.EnsureValidArgument(_fileSystem, nameof(path)); + + if (path.Length >= 4 + && path[0] == '\\' + && (path[1] == '\\' || path[1] == '?') + && path[2] == '?' + && path[3] == '\\') + { + return path; + } + + string? pathRoot = GetPathRoot(path); + string? directoryRoot = GetPathRoot(_fileSystem.Storage.CurrentDirectory); + string candidate; + if (!string.IsNullOrEmpty(pathRoot) && !string.IsNullOrEmpty(directoryRoot)) + { + if (char.ToUpperInvariant(pathRoot[0]) != char.ToUpperInvariant(directoryRoot[0])) + { + candidate = path; + } + else if (pathRoot.Length < directoryRoot.Length) + { + candidate = Combine(_fileSystem.Storage.CurrentDirectory, + path.Substring(pathRoot.Length)); + } + else + { + candidate = Combine(_fileSystem.Storage.CurrentDirectory, path); + } + } + else + { + candidate = Combine(_fileSystem.Storage.CurrentDirectory, path); + } + + string fullPath = + NormalizeDirectorySeparators(RemoveRelativeSegments(candidate, + GetRootLength(candidate))); + fullPath = fullPath.TrimEnd('.'); + + if (fullPath.Contains('\0', StringComparison.Ordinal)) + { + throw ExceptionFactory.NullCharacterInPath(nameof(path)); + } + + if (fullPath.Length > 2 && fullPath[1] == ':' && fullPath[2] != DirectorySeparatorChar) + { + return fullPath.Substring(0, 2) + DirectorySeparatorChar + fullPath.Substring(2); + } + + return fullPath; + } + +#if FEATURE_PATH_RELATIVE + /// + public override string GetFullPath(string path, string basePath) + { + path.EnsureValidArgument(_fileSystem, nameof(path)); + basePath.EnsureValidArgument(_fileSystem, nameof(basePath)); + + if (!IsPathFullyQualified(basePath)) + { + throw ExceptionFactory.BasePathNotFullyQualified(nameof(basePath)); + } + + if (IsPathFullyQualified(path)) + { + return GetFullPath(path); + } + + return GetFullPath(Combine(basePath, path)); + } +#endif + /// public override char[] GetInvalidFileNameChars() => [ @@ -49,7 +128,7 @@ public override char[] GetInvalidPathChars() => } return IsPathRooted(path) - ? path.Substring(0, Math.Min(3, path.Length)) + ? path.Substring(0, GetRootLength(path)) : string.Empty; } @@ -190,6 +269,35 @@ protected override bool IsEffectivelyEmpty(string path) return path.All(c => c == ' '); } + /// + /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L250 + /// + protected override bool IsPartiallyQualified(string path) + { + if (path.Length < 2) + { + // It isn't fixed, it must be relative. There is no way to specify a fixed + // path with one character (or less). + return true; + } + + if (IsDirectorySeparator(path[0])) + { + // There is no valid way to specify a relative path with two initial slashes or + // \? as ? isn't valid for drive relative paths and \??\ is equivalent to \\?\ + return !(path[1] == '?' || IsDirectorySeparator(path[1])); + } + + // The only way to specify a fixed path that doesn't begin with two slashes + // is the drive, colon, slash format- i.e. C:\ + return !(path.Length >= 3 + && path[1] == VolumeSeparatorChar + && IsDirectorySeparator(path[2]) + // To match old behavior we'll check the drive character for validity as the path is technically + // not qualified if you don't have a valid drive. "=:\" is the "=" file's default data stream. + && IsValidDriveChar(path[0])); + } + /// /// Returns true if the given character is a valid drive letter /// diff --git a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs index 13d82cabd..56ee80963 100644 --- a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs +++ b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs @@ -141,20 +141,31 @@ public override void SkipIfLongRunningTestsShouldBeSkipped() namespace {@class.Namespace}.{@class.Name} {{ // ReSharper disable once UnusedMember.Global - public sealed class LinuxFileSystemTests : {@class.Name} + public sealed class LinuxFileSystemTests : {@class.Name}, IDisposable {{ /// - public override string BasePath => ""/""; + public override string BasePath => _directoryCleaner.BasePath; + + private readonly IDirectoryCleaner _directoryCleaner; + public LinuxFileSystemTests() : this(new MockFileSystem(i => i.SimulatingOperatingSystem(SimulationMode.Linux))) {{ }} + private LinuxFileSystemTests(MockFileSystem mockFileSystem) : base( new Test(OSPlatform.Linux), mockFileSystem, mockFileSystem.TimeSystem) {{ + _directoryCleaner = FileSystem + .SetCurrentDirectoryToEmptyTemporaryDirectory(); }} + + /// + public void Dispose() + => _directoryCleaner.Dispose(); + /// public override void SkipIfBrittleTestsShouldBeSkipped(bool condition = true) {{ @@ -169,10 +180,13 @@ public override void SkipIfLongRunningTestsShouldBeSkipped() #endif #if !NETFRAMEWORK // ReSharper disable once UnusedMember.Global - public sealed class MacFileSystemTests : {@class.Name} + public sealed class MacFileSystemTests : {@class.Name}, IDisposable {{ /// - public override string BasePath => ""/""; + public override string BasePath => _directoryCleaner.BasePath; + + private readonly IDirectoryCleaner _directoryCleaner; + public MacFileSystemTests() : this(new MockFileSystem(i => i.SimulatingOperatingSystem(SimulationMode.MacOS))) {{ @@ -182,7 +196,14 @@ private MacFileSystemTests(MockFileSystem mockFileSystem) : base( mockFileSystem, mockFileSystem.TimeSystem) {{ + _directoryCleaner = FileSystem + .SetCurrentDirectoryToEmptyTemporaryDirectory(); }} + + /// + public void Dispose() + => _directoryCleaner.Dispose(); + /// public override void SkipIfBrittleTestsShouldBeSkipped(bool condition = true) {{ @@ -197,10 +218,13 @@ public override void SkipIfLongRunningTestsShouldBeSkipped() #endif #if !NETFRAMEWORK // ReSharper disable once UnusedMember.Global - public sealed class WindowsFileSystemTests : {@class.Name} + public sealed class WindowsFileSystemTests : {@class.Name}, IDisposable {{ /// - public override string BasePath => ""C:\\""; + public override string BasePath => _directoryCleaner.BasePath; + + private readonly IDirectoryCleaner _directoryCleaner; + public WindowsFileSystemTests() : this(new MockFileSystem(i => i.SimulatingOperatingSystem(SimulationMode.Windows))) {{ @@ -210,7 +234,14 @@ private WindowsFileSystemTests(MockFileSystem mockFileSystem) : base( mockFileSystem, mockFileSystem.TimeSystem) {{ + _directoryCleaner = FileSystem + .SetCurrentDirectoryToEmptyTemporaryDirectory(); }} + + /// + public void Dispose() + => _directoryCleaner.Dispose(); + /// public override void SkipIfBrittleTestsShouldBeSkipped(bool condition = true) {{ @@ -238,6 +269,7 @@ private bool IncludeSimulatedTests(ClassModel @class) "GetExtensionTests", "GetFileNameTests", "GetFileNameWithoutExtensionTests", + "GetFullPathTests", "GetPathRootTests", "GetRandomFileNameTests", "GetTempPathTests", diff --git a/Tests/Testably.Abstractions.Testing.Tests/Helpers/PathHelperTests.cs b/Tests/Testably.Abstractions.Testing.Tests/Helpers/PathHelperTests.cs index 95727aca4..2d351c9a4 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/Helpers/PathHelperTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/Helpers/PathHelperTests.cs @@ -127,6 +127,8 @@ public void public void ThrowCommonExceptionsIfPathIsInvalid_WithInvalidCharacters( char invalidChar) { + Skip.If(Test.IsNetFramework); + MockFileSystem fileSystem = new(i => i .SimulatingOperatingSystem(SimulationMode.Windows)); string path = invalidChar + "path"; diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetPathRootTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetPathRootTests.cs index bb436e4ac..ae832a11a 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetPathRootTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetPathRootTests.cs @@ -25,6 +25,18 @@ public void GetPathRoot_RootedDrive_ShouldReturnDriveOnWindows(string path) result.Should().Be(path); } + [SkippableTheory] + [InlineData("D:some-path", "D:")] + [InlineData("D:\\some-path", "D:\\")] + public void GetPathRoot_RootedDriveWithPath_ShouldReturnDriveOnWindows(string path, string expected) + { + Skip.IfNot(Test.RunsOnWindows); + + string? result = FileSystem.Path.GetPathRoot(path); + + result.Should().Be(expected); + } + [SkippableTheory] [AutoData] public void GetPathRoot_ShouldReturnDefaultValue(string path)