diff --git a/Lombiq.HelpfulLibraries.Common/Docs/Utilities.md b/Lombiq.HelpfulLibraries.Common/Docs/Utilities.md index e51a3d59..f4246ff7 100644 --- a/Lombiq.HelpfulLibraries.Common/Docs/Utilities.md +++ b/Lombiq.HelpfulLibraries.Common/Docs/Utilities.md @@ -8,4 +8,5 @@ - `Sha256Helper`: A static helper class with the `ComputeHash()` utility function that converts text into [SHA-256](https://en.wikipedia.org/wiki/SHA-256) hash string. - `StringHelper`: A static helper class with utility functions for concatenating and generating strings, particularly in a culture invariant manner. - `Union`: A container type which is a union of two different types. Only either of the two types can be set at the same time. +- `VersionTree`: A read-only `Version` collection that organizes the provided versions into a tree structure by version parts. Can be used to resolve a floating (partial) version from a collection of specific versions. - `XmlHelper`: Utility class to streamline XML serialization/deserialization. diff --git a/Lombiq.HelpfulLibraries.Common/Utilities/VersionTree.cs b/Lombiq.HelpfulLibraries.Common/Utilities/VersionTree.cs new file mode 100644 index 00000000..3519eed6 --- /dev/null +++ b/Lombiq.HelpfulLibraries.Common/Utilities/VersionTree.cs @@ -0,0 +1,64 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Lombiq.HelpfulLibraries.Common.Utilities; + +/// +/// Groups instances into a tree structure based on the specified version parts, so they can be +/// selected by number using indexing. +/// +/// All versions in the current subtree. +/// Versions grouped by the directly next version part. +public record VersionTree(IReadOnlyList Versions, IReadOnlyDictionary SubVersions) +{ + /// + /// Gets the subtree for the provided key, if it exists (otherwise ). If the is negative, this instance is returned instead. + /// + public VersionTree? this[int index] + { + get + { + if (index < 0) return this; + return SubVersions.TryGetValue(index, out var sub) ? sub : null; + } + } + + /// + /// Gets all versions that match the provided , that can be partial/floating. + /// + public VersionTree? this[Version? version] => + version is null + ? this + : this[version.Major]?[version.Minor]?[version.Build]?[version.Revision]; + + /// + /// Creates a new tree from a copy of the provided . + /// + public static VersionTree Create(IEnumerable versions) + { + var allVersions = versions.ToList(); + + var majorVersions = + FromVersions(allVersions, version => version.Major, major => + FromVersions(major, version => version.Minor, minor => + FromVersions(minor, version => version.Build, revision => + FromVersions(revision, version => version.Revision, _ => [])))); + + return new(allVersions, majorVersions); + } + + private static Dictionary FromVersions( + IEnumerable versions, + Func selector, + Func, Dictionary> subSelector) => + versions + .Where(version => selector(version) >= 0) + .GroupBy(selector) + .ToDictionary( + group => group.Key, + items => new VersionTree([.. items], subSelector(items))); +} diff --git a/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj b/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj index 834c188c..cb43f4af 100644 --- a/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj +++ b/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj @@ -34,4 +34,10 @@ + + + PreserveNewest + + + diff --git a/Lombiq.HelpfulLibraries.Tests/UnitTests/Utilities/VersionTreeTests.cs b/Lombiq.HelpfulLibraries.Tests/UnitTests/Utilities/VersionTreeTests.cs new file mode 100644 index 00000000..fe12c69f --- /dev/null +++ b/Lombiq.HelpfulLibraries.Tests/UnitTests/Utilities/VersionTreeTests.cs @@ -0,0 +1,61 @@ +using Lombiq.HelpfulLibraries.Common.Utilities; +using Shouldly; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using Xunit; + +namespace Lombiq.HelpfulLibraries.Tests.UnitTests.Utilities; + +public class VersionTreeTests +{ + private static readonly JsonSerializerOptions _indentedJsonOption = new() { WriteIndented = true }; + + private static readonly List _versions = + "1.0 1.2 1.2.1 1.2.2 1.2.3 1.3 1.4 1.4.1 1.4.1.1 1.4.1.2 1.4.1.3 1.4.2.1 1.4.2.2 1.4.2.4" + .Split() + .Select(Version.Parse) + .ToList(); + + [Fact] + public void VersionTreeShouldHaveExpectedStructure() + { + var tree = VersionTree.Create(_versions); + tree.Versions.ShouldBe(_versions); + + ShouldMatchJson(tree, File.ReadAllText("VersionTreeTests.FullStructure.json")); + } + + [Fact] + public void IndexingByNumberAndVersionShouldWork() + { + var tree = VersionTree.Create(_versions); + var expectedRevision = new Version(1, 4, 1, 1); + + tree[1]![4]![1]![1]!.Versions.ShouldBe([expectedRevision]); + tree[expectedRevision]!.Versions.ShouldBe([expectedRevision]); + } + + [Fact] + public void VersionSubtreeShouldHaveExpectedStructure() + { + var tree = VersionTree.Create(_versions); + var expectedSubtree = File.ReadAllText("VersionTreeTests.Subtree.json"); + + // Verify full subtree structure. + ShouldMatchJson(tree[1]![4], expectedSubtree); + + // Verify that indexing -1 results in the same. + ShouldMatchJson(tree[1]![4]![-1]![-1], expectedSubtree); + ShouldMatchJson(tree[new Version(1, 4)], expectedSubtree); + } + + private static void ShouldMatchJson(VersionTree tree, string expectedJson) + { + var actualJson = JsonSerializer.Serialize(tree, _indentedJsonOption); + (string.Join(string.Empty, actualJson.Split()) == string.Join(string.Empty, expectedJson.Split())) + .ShouldBeTrue($"Actual JSON:\n{actualJson}\nExpected JSON: {expectedJson}\n(whitespace does not matter)"); + } +} diff --git a/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.FullStructure.json b/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.FullStructure.json new file mode 100644 index 00000000..11cef9da --- /dev/null +++ b/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.FullStructure.json @@ -0,0 +1,149 @@ +{ + "Versions": [ + "1.0", + "1.2", + "1.2.1", + "1.2.2", + "1.2.3", + "1.3", + "1.4", + "1.4.1", + "1.4.1.1", + "1.4.1.2", + "1.4.1.3", + "1.4.2.1", + "1.4.2.2", + "1.4.2.4" + ], + "SubVersions": { + "1": { + "Versions": [ + "1.0", + "1.2", + "1.2.1", + "1.2.2", + "1.2.3", + "1.3", + "1.4", + "1.4.1", + "1.4.1.1", + "1.4.1.2", + "1.4.1.3", + "1.4.2.1", + "1.4.2.2", + "1.4.2.4" + ], + "SubVersions": { + "0": { + "Versions": [ + "1.0" + ], + "SubVersions": {} + }, + "2": { + "Versions": [ + "1.2", + "1.2.1", + "1.2.2", + "1.2.3" + ], + "SubVersions": { + "1": { + "Versions": [ + "1.2.1" + ], + "SubVersions": {} + }, + "2": { + "Versions": [ + "1.2.2" + ], + "SubVersions": {} + }, + "3": { + "Versions": [ + "1.2.3" + ], + "SubVersions": {} + } + } + }, + "3": { + "Versions": [ + "1.3" + ], + "SubVersions": {} + }, + "4": { + "Versions": [ + "1.4", + "1.4.1", + "1.4.1.1", + "1.4.1.2", + "1.4.1.3", + "1.4.2.1", + "1.4.2.2", + "1.4.2.4" + ], + "SubVersions": { + "1": { + "Versions": [ + "1.4.1", + "1.4.1.1", + "1.4.1.2", + "1.4.1.3" + ], + "SubVersions": { + "1": { + "Versions": [ + "1.4.1.1" + ], + "SubVersions": {} + }, + "2": { + "Versions": [ + "1.4.1.2" + ], + "SubVersions": {} + }, + "3": { + "Versions": [ + "1.4.1.3" + ], + "SubVersions": {} + } + } + }, + "2": { + "Versions": [ + "1.4.2.1", + "1.4.2.2", + "1.4.2.4" + ], + "SubVersions": { + "1": { + "Versions": [ + "1.4.2.1" + ], + "SubVersions": {} + }, + "2": { + "Versions": [ + "1.4.2.2" + ], + "SubVersions": {} + }, + "4": { + "Versions": [ + "1.4.2.4" + ], + "SubVersions": {} + } + } + } + } + } + } + } + } +} diff --git a/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.Subtree.json b/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.Subtree.json new file mode 100644 index 00000000..ecc3d553 --- /dev/null +++ b/Lombiq.HelpfulLibraries.Tests/VersionTreeTests.Subtree.json @@ -0,0 +1,69 @@ +{ + "Versions": [ + "1.4", + "1.4.1", + "1.4.1.1", + "1.4.1.2", + "1.4.1.3", + "1.4.2.1", + "1.4.2.2", + "1.4.2.4" + ], + "SubVersions": { + "1": { + "Versions": [ + "1.4.1", + "1.4.1.1", + "1.4.1.2", + "1.4.1.3" + ], + "SubVersions": { + "1": { + "Versions": [ + "1.4.1.1" + ], + "SubVersions": {} + }, + "2": { + "Versions": [ + "1.4.1.2" + ], + "SubVersions": {} + }, + "3": { + "Versions": [ + "1.4.1.3" + ], + "SubVersions": {} + } + } + }, + "2": { + "Versions": [ + "1.4.2.1", + "1.4.2.2", + "1.4.2.4" + ], + "SubVersions": { + "1": { + "Versions": [ + "1.4.2.1" + ], + "SubVersions": {} + }, + "2": { + "Versions": [ + "1.4.2.2" + ], + "SubVersions": {} + }, + "4": { + "Versions": [ + "1.4.2.4" + ], + "SubVersions": {} + } + } + } + } +}