diff --git a/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs b/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs index 72a123b8..6e5e7f80 100644 --- a/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs +++ b/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs @@ -18,13 +18,31 @@ public sealed record AbsoluteDirectoryPath : SemanticDirectoryPath /// Gets the parent directory of this absolute directory path. /// - /// An representing the parent directory, or an empty path if this is a root directory. + /// + /// An representing the parent directory, or this same path when it is a + /// root directory and therefore has no parent above it. + /// + /// + /// A root is its own parent, matching how the filesystem resolves /.. to / and C:\.. to + /// C:\. This keeps every value this property returns a usable absolute directory rather than an empty + /// path, which silently reads as "no contents" when passed to the filesystem. Use , or + /// compare the parent against this path, to detect that walking upwards has finished. + /// public AbsoluteDirectoryPath Parent { get { - return _cachedParent ??= Create( - InternedPathStrings.InternIfCommon(Path.GetDirectoryName(WeakString) ?? InternedPathStrings.Empty)); + if (_cachedParent is not null) + { + return _cachedParent; + } + + string? parent = Path.GetDirectoryName(WeakString); + _cachedParent = string.IsNullOrEmpty(parent) + ? this + : Create(InternedPathStrings.InternIfCommon(parent)); + + return _cachedParent; } } diff --git a/Semantics.Test/Paths/AbsoluteDirectoryPathParentTests.cs b/Semantics.Test/Paths/AbsoluteDirectoryPathParentTests.cs new file mode 100644 index 00000000..6c51381e --- /dev/null +++ b/Semantics.Test/Paths/AbsoluteDirectoryPathParentTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Paths; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ktsu.Semantics.Paths; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for walking upwards from an . +/// +[TestClass] +public class AbsoluteDirectoryPathParentTests +{ + private static AbsoluteDirectoryPath Dir(string path) => AbsoluteDirectoryPath.Create(path); + + private static string Nested => OperatingSystem.IsWindows() ? @"C:\Users\user\Documents" : "/home/user/Documents"; + + private static string Middle => OperatingSystem.IsWindows() ? @"C:\Users\user" : "/home/user"; + + private static string BelowRoot => OperatingSystem.IsWindows() ? @"C:\Users" : "/home"; + + private static string Root => OperatingSystem.IsWindows() ? @"C:\" : "/"; + + [TestMethod] + public void Parent_ReturnsTheContainingDirectory() + { + Assert.AreEqual(Middle, Dir(Nested).Parent.WeakString); + Assert.AreEqual(BelowRoot, Dir(Middle).Parent.WeakString); + Assert.AreEqual(Root, Dir(BelowRoot).Parent.WeakString); + } + + [TestMethod] + public void Parent_OfRoot_ReturnsTheRootItself() + { + AbsoluteDirectoryPath root = Dir(Root); + + Assert.IsTrue(root.IsRoot, $"'{root}' should be a root."); + Assert.AreEqual(root, root.Parent); + } + + [TestMethod] + public void Parent_OfUncShareRoot_ReturnsTheShareItself() + { + if (!OperatingSystem.IsWindows()) + { + Assert.Inconclusive("UNC paths are Windows-only."); + return; + } + + Assert.AreEqual(@"\\server\share", Dir(@"\\server\share\folder").Parent.WeakString); + + AbsoluteDirectoryPath share = Dir(@"\\server\share"); + Assert.AreEqual(share, share.Parent); + } + + /// + /// Regression test for ktsu-dev/ImGuiApp#281. A root used to report an empty parent, which still passes + /// validation but is not a location: passed to the filesystem it reads + /// as "no contents" rather than failing, so callers walking upwards silently landed nowhere. + /// + [TestMethod] + public void Parent_IsAlwaysAUsableAbsolutePath() + { + AbsoluteDirectoryPath current = Dir(Environment.CurrentDirectory); + int stepsPastRoot = current.Depth + 2; + + for (int step = 0; step < stepsPastRoot; step++) + { + AbsoluteDirectoryPath parent = current.Parent; + + Assert.IsFalse(string.IsNullOrEmpty(parent.WeakString), $"The parent of '{current}' should not be empty."); + Assert.IsTrue( + Path.IsPathFullyQualified(parent.WeakString), + $"The parent of '{current}' should be fully qualified, but was '{parent}'."); + + current = parent; + } + + Assert.IsTrue(current.IsRoot, $"Walking upwards should settle on a root, but settled on '{current}'."); + } + + [TestMethod] + public void Parent_OfRoot_IsAFixedPointSoWalkingUpwardsTerminates() + { + AbsoluteDirectoryPath current = Dir(Environment.CurrentDirectory); + int steps = 0; + + while (!current.IsRoot) + { + current = current.Parent; + Assert.IsLessThan(1000, ++steps, "Walking upwards should terminate at a root."); + } + + Assert.AreEqual(current, current.Parent); + } + + [TestMethod] + public void GetAncestors_EndsAtTheRootWithoutRepeatingIt() + { + List ancestors = [.. Dir(Nested).GetAncestors()]; + + CollectionAssert.AreEqual( + new List { Dir(Middle), Dir(BelowRoot), Dir(Root) }, + ancestors, + $"Expected the chain up to the root, got [{string.Join(", ", ancestors.Select(a => a.WeakString))}]."); + } + + [TestMethod] + public void GetAncestors_OfRoot_IsEmpty() + { + Assert.IsEmpty(Dir(Root).GetAncestors()); + } +}