From f702e732d816e71890790436ac92e04ae0991d14 Mon Sep 17 00:00:00 2001 From: Alexander Linne Date: Sun, 25 Oct 2026 20:07:06 +0100 Subject: [PATCH] feat: match slice patterns with regex like ArchUnit Slice patterns were matched by string surgery: the pattern was split at the first "(*", and everything after it was discarded and replaced with "(**).". Every pattern therefore behaved as if it ended in a greedy capture, so "(*)", "(*)..", "(*).(*)" and "(*)..(*)" all produced the same nine slices, a trailing literal such as "(**).Service.." matched nothing, and alternation and wildcards were ignored outright. Convert the pattern to a regular expression instead, porting PackageMatcher.convertToRegex from ArchUnit. "(*)" now captures exactly one namespace segment, "(**)" one or more, "*" matches a segment without capturing it, ".." skips segments, and "[A|B]" alternates. "(A|B)" is a capture group, as it is in ArchUnit: the regex captures it like any other group, so the "has to contain a capture group" check accepts it as well. ".." deviates from upstream. ArchUnit's TWO_DOTS_REGEX, (?:(?:^\w*)?\.(?:\w+\.)*(?:\w*$)?)? is optional as a whole and brings its own dots, so it also matches the empty string in the middle of a segment ("App.(*)..(*)" on App.Single would capture ["Singl", "e"]). Expand ".." depending on where it sits in the pattern instead, so the separating dot is always consumed: leading (?:\w+\.)* skip whole leading segments trailing (?:\.\w+)* skip whole trailing segments inner \.(?:\w+\.)* the separating dot, then whole segments The expansion stays greedy; only the mid-segment matches are gone. The deviation is recorded on the method. Every capture group now captures a part of the slice, so a pattern with several groups gives slices with several parts, named like ArchUnit: App.(*).(*) App.Orders.Http Slice Orders - Http App.(*).Service.(*) App.Orders.Service.Http Slice Orders - Http App.(*)..(*) App.Orders.Web.Http Slice Orders - Http Nested diagrams need nothing from the pattern; slices with several parts nest at their types' common namespace like any other, and focus mode and LimitDependencies, which use that namespace too, need no change. Pattern validation moves from the per-type assignment callback to Matching/MatchingWithPackages, so a malformed pattern is rejected where it is written rather than on the first type that happens to be checked. The "has to contain a capture group" check is deliberately last: a pattern that is malformed *and* has no capture group should report the specific problem. The old matcher could produce slice names containing dots even for a pattern made only of "(*)" groups, and PlantUmlFileBuilder compensated with RemovePatternInappropriateSlices, which dropped every slice with at least as many dots as the pattern had "(*)" groups. Now that "(*)" expands to \w+, a captured part can never contain a dot, so that filter can no longer remove anything. Remove it along with the CountOfAsteriskInPattern it consumed. Document the pattern syntax and the multi-capture naming on SliceRuleInitializer and SliceIdentifier, and add the pattern token table and the multi-capture naming rules to the guide's "Slice Rules" section. Visible in the tests: - "SubnamespaceCircle.(*).." now folds Slice2.Inner into Slice2 and so reports the cycle Slice1 -> Slice2 -> Slice1. It used to give Inner a slice of its own and see no cycle. - "MultipleSubnamespaces.(**).." no longer drops the top-level classes, so it now sees the Slice3 -> Slice1 edge that closes the loop. - The DotDotSemantics fixture shows ".." no longer splitting "AlphaService" or merging it with "Alpha.Service". BREAKING CHANGE: slice membership follows the pattern now. "(*)" matches exactly one namespace segment, so Matching("App.(*)") leaves out every type below App.. instead of giving it a slice of its own -- use "App.(*).." to fold those types into their top-level slice, or "App.(**)" to keep one slice per namespace. "(**).." now also includes the types directly in the captured namespace. Rules can therefore report different violations, and slice names in failure messages and frozen-rule stores change along with the slices. Rules with several capture groups have to be re-frozen once. BREAKING CHANGE: Slice.CountOfAsteriskInPattern, SliceIdentifier.CountOfAsteriskInPattern and the countOfAsteriskInPattern parameter of SliceIdentifier.Of are gone. Signed-off-by: Alexander Linne --- .../PlantUml/Export/PlantUmlFileBuilder.cs | 24 -- ArchUnitNET/Domain/Slice.cs | 2 - ArchUnitNET/Domain/SliceIdentifier.cs | 35 +- .../Fluent/Slices/SliceRuleInitializer.cs | 283 +++++++++++----- .../ArchUnitNET/Storage/FrozenRules.json | 6 + .../ArchUnitNET/Storage/FrozenRules.xml | 3 + .../PlantUml/PlantUmlSliceDiagramTests.cs | 143 ++++++-- ...amePartAfterDifferentPrefixes.verified.txt | 27 ++ ...d_LiteralBetweenCaptureGroups.verified.txt | 11 + ...sNested_MultipleCaptureGroups.verified.txt | 26 +- ...ed_NonContiguousCaptureGroups.verified.txt | 27 +- ...ntiguousCaptureGroups_C4Style.verified.txt | 26 ++ ...ptureGroups_LimitDependencies.verified.txt | 26 ++ ...esisedAlternationAsFirstGroup.verified.txt | 17 + ...amePartAfterDifferentPrefixes.verified.txt | 32 ++ ...sted_WildcardBeforeFirstGroup.verified.txt | 19 ++ ..._WildcardBetweenCaptureGroups.verified.txt | 11 + ...amePartAfterDifferentPrefixes.verified.txt | 17 + ...ySlices_MultipleCaptureGroups.verified.txt | 18 +- ...es_NonContiguousCaptureGroups.verified.txt | 19 +- ArchUnitNETTests/Fluent/FreezeTests.cs | 12 + .../Slices/MatchingWithPackagesTests.cs | 3 + .../Fluent/Slices/PatternValidationTests.cs | 51 +-- .../Fluent/Slices/SliceDomainTests.cs | 39 ++- .../Slices/SliceRuleCombinationTests.cs | 7 +- ArchUnitNETTests/Fluent/Slices/SlicesTests.cs | 311 ++++++++++++------ ...sDescriptiveDependencyMessage.verified.txt | 51 ++- .../AlphaService/AlphaServiceClass.cs | 7 +- documentation/docs/guide.md | 31 +- 29 files changed, 907 insertions(+), 377 deletions(-) create mode 100644 ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesFocusOn_SamePartAfterDifferentPrefixes.verified.txt create mode 100644 ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_LiteralBetweenCaptureGroups.verified.txt create mode 100644 ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_C4Style.verified.txt create mode 100644 ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_LimitDependencies.verified.txt create mode 100644 ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_ParenthesisedAlternationAsFirstGroup.verified.txt create mode 100644 ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SamePartAfterDifferentPrefixes.verified.txt create mode 100644 ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBeforeFirstGroup.verified.txt create mode 100644 ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBetweenCaptureGroups.verified.txt create mode 100644 ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_LimitDependencies_SamePartAfterDifferentPrefixes.verified.txt diff --git a/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs b/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs index 8a2b66ac7..b12e1864a 100644 --- a/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs +++ b/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs @@ -146,7 +146,6 @@ public PlantUmlFileBuilder WithDependenciesFrom( { _sliceList = slices.Distinct().ToList(); AssertDistinctNames(); - RemovePatternInappropriateSlices(); AssignCommonNamespaces(); if (generationOptions == null) @@ -340,7 +339,6 @@ public PlantUmlFileBuilder WithDependenciesFromFocusOn( package = package.Remove(package.Length - 1); } - RemovePatternInappropriateSlices(); AssignCommonNamespaces(); var focusedSlices = new HashSet( @@ -448,28 +446,6 @@ GenerationOptions generationOptions ); } - private void RemovePatternInappropriateSlices() - { - if (_sliceList.Any(slice => slice.CountOfAsteriskInPattern == null)) - { - return; - } - - for (var i = _sliceList.Count - 1; i >= 0; i--) - { - var dots = 0; - - // The description is never qualified, so its dots are those below the namespace - // the pattern matched before its capture group. - dots += _sliceList[i].Description.Count(c => c == '.'); - - if (dots >= _sliceList[i].CountOfAsteriskInPattern) - { - _sliceList.RemoveAt(i); - } - } - } - private void RemoveDuplicatedArrowsIfExist() { for (var i = _dependencies.Count - 1; i >= 0; i--) diff --git a/ArchUnitNET/Domain/Slice.cs b/ArchUnitNET/Domain/Slice.cs index 0c1bb482b..9db857c3e 100644 --- a/ArchUnitNET/Domain/Slice.cs +++ b/ArchUnitNET/Domain/Slice.cs @@ -26,8 +26,6 @@ public Slice(SliceIdentifier identifier, IEnumerable types) public string Description => Identifier.Description; - public int? CountOfAsteriskInPattern => Identifier.CountOfAsteriskInPattern; - /// /// Whether diagrams nest this slice in packages even if /// is not set, which diff --git a/ArchUnitNET/Domain/SliceIdentifier.cs b/ArchUnitNET/Domain/SliceIdentifier.cs index 375e69aaf..ce2f2b1ed 100644 --- a/ArchUnitNET/Domain/SliceIdentifier.cs +++ b/ArchUnitNET/Domain/SliceIdentifier.cs @@ -7,28 +7,32 @@ namespace ArchUnitNET.Domain /// /// Identifies a slice by its parts: for a slice pattern, the values its capture groups /// captured, in group order. Two identifiers are the same slice exactly when their parts are - /// equal and both are ignored or both are not. + /// equal and both are ignored or both are not. See + /// . /// public class SliceIdentifier : IHasDescription { + /// + /// Groups identifiers by slice membership; see . + /// public static readonly SliceIdentifierComparer Comparer = new SliceIdentifierComparer(); + + /// + /// Whether the type that produced this identifier failed to match the pattern and is + /// therefore left out of the rule. + /// public readonly bool Ignored; - private SliceIdentifier( - IReadOnlyList parts, - bool ignored, - int? countOfAsteriskInPattern = null - ) + private SliceIdentifier(IReadOnlyList parts, bool ignored) { Parts = parts; Ignored = ignored; - CountOfAsteriskInPattern = countOfAsteriskInPattern; } /// /// What identifies the slice: for a slice pattern, the values its capture groups - /// captured, in group order. "App.(**)" over "App.Orders.Web" gives - /// [Orders.Web]. + /// captured, in group order. "App.(*).(*)" over "App.Orders.Http" gives + /// [Orders, Http], and so does "App.(*)..(*)" over "App.Orders.Web.Http". /// public IReadOnlyList Parts { get; } @@ -39,8 +43,6 @@ private SliceIdentifier( /// public string Description => "Slice " + string.Join(" - ", Parts); - public readonly int? CountOfAsteriskInPattern; - /// /// Creates an identifier for a slice with a single part, the same slice as /// Of(new[] { part }). @@ -49,12 +51,9 @@ private SliceIdentifier( /// The slice's only part: a custom SliceAssignment that returns identifiers with the /// same part for two types puts them into the same slice. /// - /// - /// The number of "(*)" groups in the pattern, if the slice comes from one. - /// - public static SliceIdentifier Of(string part, int? countOfAsteriskInPattern = null) + public static SliceIdentifier Of(string part) { - return new SliceIdentifier(CheckParts(new[] { part }), false, countOfAsteriskInPattern); + return Of(new[] { part }); } /// @@ -68,6 +67,10 @@ public static SliceIdentifier Of(IEnumerable parts) return new SliceIdentifier(CheckParts(parts), false); } + /// + /// Creates the identifier for a type that did not match the pattern. All such types + /// share one slice, which the rule then leaves out. + /// public static SliceIdentifier Ignore() { return new SliceIdentifier(new[] { "Ignored" }, true); diff --git a/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs b/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs index 00b67c11f..c7201eb18 100644 --- a/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs +++ b/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs @@ -1,24 +1,62 @@ -using System; +// Adapted from https://github.com/TNG/ArchUnit/blob/main/archunit/src/main/java/com/tngtech/archunit/core/domain/PackageMatcher.java + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; using ArchUnitNET.Domain; namespace ArchUnitNET.Fluent.Slices { + /// + /// Assigns types to slices by matching their namespaces against a pattern. + /// + /// + /// A pattern is a namespace with these tokens in it: + /// + /// (*)captures exactly one segment + /// (**)captures one or more segments + /// (A|B)captures one of the alternatives + /// *matches one segment without capturing it + /// ..matches zero or more segments + /// [A|B]matches one of the alternatives + /// + /// + /// A pattern must contain at least one capture group, may not mix (*) and (**), and may + /// use (**) at most once. Types whose namespace does not match are left out of the rule. + /// + /// + /// A slice is identified by what the capture groups captured, in group order + /// (): types that captured the same values share a + /// slice. "App.(*).(*)", "App.(*).Service.(*)" and "App.(*)..(*)" all put the types they + /// match in "Orders" and "Http" into the slice [Orders, Http], while "App.(**)" + /// over "App.Orders.Http" captures the single value [Orders.Http]. + /// + /// + /// The slice is named after its parts, e.g. "Slice Orders - Http"; see + /// . + /// + /// public class SliceRuleInitializer { private readonly SliceRuleCreator _ruleCreator; + /// + /// Initializes a new instance with the given rule creator. + /// + /// The rule creator that accumulates the rule definition. public SliceRuleInitializer(SliceRuleCreator ruleCreator) { _ruleCreator = ruleCreator; } /// + /// Groups types into slices by what the pattern captured. /// /// - /// check https://www.archunit.org/userguide/html/000_Index.html#_slices for examples for pattern - /// usage + /// The namespace pattern; see the remarks on . /// - /// + /// The pattern is malformed. public GivenSlices Matching(string pattern) { return Matching(pattern, false); @@ -30,6 +68,7 @@ public GivenSlices Matching(string pattern) /// . /// /// The namespace pattern, as for . + /// The pattern is malformed. [Obsolete( "Use Matching(pattern) and draw the slices with " + "new GenerationOptions { NestSlicesInPackages = true }. MatchingWithPackages " @@ -42,13 +81,10 @@ public GivenSlices MatchingWithPackages(string pattern) private GivenSlices Matching(string pattern, bool nestByDefault) { + var regex = ConvertPatternToRegex(pattern); _ruleCreator.SetSliceAssignment( new SliceAssignment( - t => - { - var parseResult = Parse(pattern); - return AssignFunc(t, parseResult.pattern, parseResult.asterisk); - }, + type => AssignFunc(type, regex), "matching \"" + pattern + "\"", nestByDefault ) @@ -56,118 +92,191 @@ private GivenSlices Matching(string pattern, bool nestByDefault) return new GivenSlices(_ruleCreator); } - private static (string pattern, int? asterisk) Parse(string pattern) + private const string TwoStarCaptureLiteral = "(**)"; + private const string TwoStarRegexMarker = "#%#%#"; + + /// Captures one or more namespace segments, separated by dots. + private const string TwoStarCaptureRegex = @"(\w+(?:\.\w+)*)"; + + /// Captures exactly one namespace segment. + private const string SingleStarCaptureRegex = @"(\w+)"; + + /// Matches an escaped ".." in an already escaped pattern. + private static readonly Regex EscapedTwoDots = new Regex( + @"\\\.\\\.", + RegexOptions.Compiled + ); + + internal static Regex ConvertPatternToRegex(string pattern) { - var indexOfAsteriskInPattern = pattern.IndexOf("(*", StringComparison.Ordinal); - var containsSingleAsterisk = pattern.Contains("(*)"); - var containsDoubleAsterisk = pattern.Contains("(**)"); + AssertPatternIsValid(pattern); + var escaped = Regex + .Replace(pattern, @"\[(.*?)]", "(?:$1)") + .Replace(TwoStarCaptureLiteral, TwoStarRegexMarker) + .Replace("(*)", SingleStarCaptureRegex) + .Replace("*", @"\w+") + .Replace(".", @"\."); + var result = EscapedTwoDots + .Replace(escaped, match => TwoDotsRegex(match, escaped)) + .Replace(TwoStarRegexMarker, TwoStarCaptureRegex); + return new Regex($"^{result}$", RegexOptions.Compiled); + } - if (!containsSingleAsterisk && !containsDoubleAsterisk) + /// + /// Expands one ".." into a regex matching zero or more whole namespace segments, + /// together with the dot that separates them from what surrounds the "..". + /// + /// + /// Consuming the boundary dot unconditionally is what keeps ".." on segment boundaries. + /// An expression that is optional as a whole and brings its own dots also matches the + /// empty string in the middle of a segment: "App.(*)..(*)" then splits "App.Slice3" into + /// "Slice" and "3", and "App.(*)..Service" matches "App.MyService" as if "MyService" + /// were two segments. + /// + private static string TwoDotsRegex(Match match, string escapedPattern) + { + if (match.Index == 0) { - throw new ArgumentException("Patterns for Slices have to contain (*) or (**)."); + return @"(?:\w+\.)*"; } - if (containsDoubleAsterisk && containsSingleAsterisk) + return match.Index + match.Length == escapedPattern.Length + ? @"(?:\.\w+)*" + : @"\.(?:\w+\.)*"; + } + + private static readonly Regex IllegalAlternation = new Regex( + @"\[[^|]*\]", + RegexOptions.Compiled + ); + + private static readonly Regex IllegalNestedGroup = new Regex( + @"\([^)]*\(|\([^)]*\[|\[[^\]]*\(|\[[^\]]*\[", + RegexOptions.Compiled + ); + + /// + /// Every "(...)" in the pattern is a capture group: "(*)", "(**)" and an alternation such + /// as "(A|B)". Nesting is rejected up front, so no group contains another. + /// + private static readonly Regex CaptureGroup = new Regex( + @"\([^()]*\)", + RegexOptions.Compiled + ); + + private static void AssertPatternIsValid(string pattern) + { + if (pattern.Contains("...")) { - throw new ArgumentException("Patterns for Slices can't contain both (*) and (**)."); + throw new ArgumentException( + "Pattern may not contain more than two '.' in a row", + nameof(pattern) + ); } - if ( - pattern.IndexOf("(**", StringComparison.Ordinal) - != pattern.LastIndexOf("(**", StringComparison.Ordinal) - ) + if (pattern.Replace("(**)", "").Contains("**")) { - throw new ArgumentException("Patterns for Slices can contain (**) only once."); + throw new ArgumentException( + "Pattern may not contain more than one '*' in a row", + nameof(pattern) + ); } - if (containsDoubleAsterisk) + if (pattern.Contains("(..)")) { - return (pattern, null); + throw new ArgumentException( + "Pattern does not support capturing via (..), use (**) instead", + nameof(pattern) + ); } - var countOfSingleAsterisk = - pattern.Split(new[] { "(*)" }, StringSplitOptions.None).Length - 1; - pattern = pattern.Remove(indexOfAsteriskInPattern) + "(**)."; - return (pattern, countOfSingleAsterisk); - } - - private static SliceIdentifier AssignFunc( - IType type, - string pattern, - int? countOfSingleAsterisk - ) - { - var indexOfAsteriskInPattern = pattern.IndexOf("(*", StringComparison.Ordinal); - - var namespc = type.Namespace.FullName; - var slicePrefix = pattern.Remove(indexOfAsteriskInPattern); - var slicePostfix = pattern.Substring( - pattern.IndexOf("*)", StringComparison.Ordinal) + 2 - ); - - if (slicePrefix.StartsWith(".")) + if (IllegalAlternation.IsMatch(pattern)) { - slicePrefix = slicePrefix.Substring(1); - if (!namespc.Contains(slicePrefix)) - { - return SliceIdentifier.Ignore(); - } + throw new ArgumentException( + "Pattern does not allow alternation brackets '[]' without specifying any alternative via '|' inside", + nameof(pattern) + ); } - else if (!namespc.StartsWith(slicePrefix)) + + if (ContainsToplevelAlternation(pattern)) { - return SliceIdentifier.Ignore(); + throw new ArgumentException( + "Pattern only supports '|' inside of '[]' or '()'", + nameof(pattern) + ); } - if (slicePostfix.EndsWith(".")) + if (IllegalNestedGroup.IsMatch(pattern)) { - slicePostfix = slicePostfix.Remove(slicePostfix.Length - 1); - if ( - !namespc - .Substring( - namespc.IndexOf(slicePrefix, StringComparison.Ordinal) - + slicePrefix.Length - ) - .Contains(slicePostfix) - ) - { - return SliceIdentifier.Ignore(); - } + throw new ArgumentException( + "Namespace identifier does not support nesting '()' or '[]' within other '()' or '[]'", + nameof(pattern) + ); } - else if (!namespc.EndsWith(slicePostfix)) + + if (pattern.Contains("(*)") && pattern.Contains("(**)")) { - return SliceIdentifier.Ignore(); + throw new ArgumentException("Patterns for Slices can't contain both (*) and (**)."); } - var sliceString = namespc; + if ( + pattern.IndexOf("(**)", StringComparison.Ordinal) + != pattern.LastIndexOf("(**)", StringComparison.Ordinal) + ) + { + throw new ArgumentException("Patterns for Slices can contain (**) only once."); + } - if (slicePrefix != "") + // Checked last on purpose: a pattern that is malformed *and* has no capture group + // should report the specific problem rather than this catch-all. + if (!CaptureGroup.IsMatch(pattern)) { - sliceString = namespc - .Substring( - namespc.IndexOf(slicePrefix, StringComparison.Ordinal) + slicePrefix.Length - ) - .TrimStart('.'); + throw new ArgumentException("Patterns for Slices have to contain (*) or (**)."); } + } - if (!sliceString.Contains(slicePostfix)) + private static bool ContainsToplevelAlternation(string pattern) + { + var depth = 0; + foreach (var c in pattern) { - throw new ArgumentException( - "\"" - + type.FullName - + "\" is not clearly assignable to a slice with the pattern: \"" - + pattern - + "\"" - ); + switch (c) + { + case '(': + case '[': + depth++; + break; + case ')': + case ']': + depth--; + break; + case '|' when depth == 0: + return true; + } } - if (slicePostfix != "") + return false; + } + + private static SliceIdentifier AssignFunc(IType type, Regex regex) + { + var namespc = type.Namespace.FullName; + var match = regex.Match(namespc); + if (!match.Success) { - sliceString = sliceString.Remove( - sliceString.IndexOf(slicePostfix, StringComparison.Ordinal) - ); + return SliceIdentifier.Ignore(); } - return SliceIdentifier.Of(sliceString, countOfSingleAsterisk); + return SliceIdentifier.Of(Captures(match)); + } + + /// + /// What the pattern's capture groups captured, in group order. + /// + internal static IReadOnlyList Captures(Match match) + { + // Group 0 is the whole match; the rest are the pattern's capture groups in order. + return match.Groups.Cast().Skip(1).Select(group => group.Value).ToList(); } } } diff --git a/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.json b/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.json index 93b5d4c34..77b8bbcb3 100644 --- a/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.json +++ b/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.json @@ -29,6 +29,12 @@ "Slice Slice1.Service" ] }, + { + "ArchRuleDescription": "Slices matching \"SlicesTestAssembly.MultipleSubnamespaces.(*).(*)\" should not depend on each other", + "Violations": [ + "Slice Slice1 - Service" + ] + }, { "ArchRuleDescription": "Slices matching \"SlicesTestAssembly.MultipleSubnamespaces.(*)..\" should not depend on each other", "Violations": [] diff --git a/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.xml b/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.xml index 785f1107e..e7260ef90 100644 --- a/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.xml +++ b/ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.xml @@ -19,4 +19,7 @@ Slice Slice1 Slice Slice1.Service + + Slice Slice1 - Service + \ No newline at end of file diff --git a/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs b/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs index d88b9e718..dbc341b24 100644 --- a/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs +++ b/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs @@ -26,23 +26,10 @@ public class PlantUmlSliceDiagramTests private static GenerationOptions Nested => new GenerationOptions { NestSlicesInPackages = true }; - // --- The asterisk count decides which slices the exporter renders --------------- + // --- Each pattern shape groups MultipleSubnamespaces differently ---------------- // - // Every pattern below matches the same nine slices -- SlicesTests.MatchingTest and - // Matching_SingleAsterisk_CapturesEveryDepth assert that. What differs is how many - // of them survive PlantUmlFileBuilder.RemovePatternInappropriateSlices, which drops - // a slice once its depth (dots in its Description) reaches - // Slice.CountOfAsteriskInPattern: - // - // (*) count 1 -> 3 of 9 slices rendered - // (*).(*) count 2 -> 7 of 9 slices rendered - // (**) count null -> 9 of 9 slices rendered - // - // CountOfAsteriskInPattern has no other consumer in the library: the slice domain - // carries it solely so the exporter can perform that deletion. So these snapshots - // are the only record that asking for "(*)" hands the diagram two-thirds fewer - // nodes than GetObjects returned, and a reimplementation is free to drop the field - // from SliceIdentifier only if it also changes what these files say. + // SlicesTests pins which slices each pattern produces; these snapshots pin how the + // exporter draws them. [Fact] public Task BuildUmlBySlices_SingleAsterisk() @@ -56,17 +43,6 @@ public Task BuildUmlBySlices_DoubleAsterisk() return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "(**)")); } - // The matcher discards everything after the first "(*", so "(*).(*)" and "(*)..(*)" - // are indistinguishable to it and the next two snapshots are byte-identical -- as are - // their nested counterparts below. That identity is the point: it is - // what a reimplementation giving the second capture group any meaning would have to - // change. SlicesTests.DotDot_* cover the shapes that would tell the two patterns - // apart. - // - // Identical snapshots do not make these tests redundant with "(*)", though. The - // second group is dead to the matcher but still raises CountOfAsteriskInPattern to - // 2, which moves the exporter's cutoff -- hence seven nodes here against three for - // "(*)". [Fact] public Task BuildUmlBySlices_MultipleCaptureGroups() { @@ -103,6 +79,36 @@ public Task BuildUmlBySlicesNested_NonContiguousCaptureGroups() return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "(*)..(*)"), Nested); } + [Fact] + public Task BuildUmlBySlicesNested_LiteralBetweenCaptureGroups() + { + // Only the types decide where a slice sits: "Slice Slice3 - Inner" sits at + // Slice3.Group1.Inner here, but at Slice3 for "(*).*.(*)" below. + return VerifySlices( + SliceRuleDefinition.Slices().Matching(Root + "(*).Group1.(*)"), + Nested + ); + } + + [Fact] + public Task BuildUmlBySlicesNested_WildcardBetweenCaptureGroups() + { + // "Slice Slice3 - Inner" holds the types of both Group1.Inner and Group2.Inner, so it + // sits at Slice3, the namespace they share. + return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "(*).*.(*)"), Nested); + } + + [Fact] + public Task BuildUmlBySlicesNested_NonContiguousCaptureGroups_LimitDependencies() + { + // No name has a dot, but "Slice Slice3 - Inner" sits at depth 3 and the others at + // depth 4, so only "Slice Slice1 - Service" -> "Slice Slice2 - Service" stays. + return VerifySlices( + SliceRuleDefinition.Slices().Matching(Root + "(*)..(*)"), + new GenerationOptions { NestSlicesInPackages = true, LimitDependencies = true } + ); + } + [Fact] public Task BuildUmlBySlicesNested_CaptureGroupAtStart() { @@ -140,6 +146,15 @@ public Task BuildUmlBySlices_C4Style() ); } + [Fact] + public Task BuildUmlBySlicesNested_NonContiguousCaptureGroups_C4Style() + { + return VerifySlices( + SliceRuleDefinition.Slices().Matching(Root + "(*)..(*)"), + new GenerationOptions { NestSlicesInPackages = true, C4Style = true } + ); + } + [Fact] public Task BuildUmlBySlicesFocusOn() { @@ -227,14 +242,25 @@ public Task BuildUmlBySlices_LimitDependencies_Compact() return VerifyUml(uml); } + [Fact] + public Task BuildUmlBySlices_LimitDependencies_SamePartAfterDifferentPrefixes() + { + // Group1, Service and Slice3 have no dots, but lie at depths 4, 2 and 3, so of the + // arrows between them only Slice1 -> Slice2 -> Slice3 -> Slice1 stays. + var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + ".(*)")); + var uml = new PlantUmlFileBuilder() + .WithDependenciesFrom(slices, new GenerationOptions { LimitDependencies = true }) + .AsString(); + return VerifyUml(uml); + } + // --- IncludeNodesWithoutDependencies = false removes dependency-less slices ---- [Fact] public Task BuildUmlBySlices_ExcludeNodesWithoutDependencies() { - // A single-asterisk pattern would already collapse away the dependency-less slices - // via RemovePatternInappropriateSlices, so use "(**)" to keep them in the slice list - // and let IncludeNodesWithoutDependencies do the removal instead. + // "(**)" keeps every namespace as a slice of its own, including ones without any + // dependency, so there is something for IncludeNodesWithoutDependencies to remove. var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)")); var uml = new PlantUmlFileBuilder() .WithDependenciesFrom( @@ -250,8 +276,8 @@ public Task BuildUmlBySlices_ExcludeNodesWithoutDependencies() [Fact] public Task BuildUmlBySlices_WithDependencyFilter() { - // "(*)" would already collapse Slice3.Group1 away via RemovePatternInappropriateSlices, - // leaving no dependency for the filter to remove, so use "(**)" instead. + // "(**)" gives Slice3.Group1 a slice of its own, so there is a dependency for the + // filter to remove. var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)")); var uml = new PlantUmlFileBuilder() .WithDependenciesFrom( @@ -354,8 +380,8 @@ public void BuildUmlBySlicesFocusOn_TrailingDotPackage_IsTrimmed() [Fact] public Task BuildUmlBySlicesFocusOn_SingleAsteriskPattern() { - // RemovePatternInappropriateSlices leaves the three slices "(*)" stands for, of - // which "Slice3" lies in the namespace focused on. + // "(*)" captures a single segment, so the slices are Slice1 to Slice3, of which + // "Slice3" lies in the namespace focused on. var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(*)")); var uml = new PlantUmlFileBuilder() .WithDependenciesFromFocusOn(slices, Root + "Slice3", Nested) @@ -363,6 +389,19 @@ public Task BuildUmlBySlicesFocusOn_SingleAsteriskPattern() return VerifyUml(uml); } + [Fact] + public Task BuildUmlBySlicesFocusOn_SamePartAfterDifferentPrefixes() + { + // Slice3, Group1, Group2 and Inner lie in Slice3, although their names don't say so. + // Group2 and Inner have no arrows and are left out like any such slice; Service, + // Slice1 and Slice2 are drawn as neighbours. + var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + ".(*)")); + var uml = new PlantUmlFileBuilder() + .WithDependenciesFromFocusOn(slices, Root + "Slice3", Nested) + .AsString(); + return VerifyUml(uml); + } + [Fact] public void BuildUmlBySlicesFocusOn_SiblingWithSamePrefix_IsNotFocused() { @@ -418,6 +457,17 @@ public void BuildUmlBySlicesFocusOn_FocusesTheSameSlicesFlatAndNested() // A nested slice sits in the package of the deepest namespace that contains all of its // types. The packages come from those namespaces, not from the pattern. + [Fact] + public Task BuildUmlBySlicesNested_ParenthesisedAlternationAsFirstGroup() + { + // The alternation captures Slice1 or Slice2, and the ".." folds their Service + // namespaces into them, so there are two slices and no Slice3. + return VerifySlices( + SliceRuleDefinition.Slices().Matching(Root + "(Slice1|Slice2).."), + Nested + ); + } + [Fact] public Task BuildUmlBySlicesNested_DuplicatePrefixSegment() { @@ -501,6 +551,31 @@ public void BuildUmlBySlicesNested_SliceWithoutCommonNamespace_IsDrawnOnItsOwn() ); } + [Fact] + public Task BuildUmlBySlicesNested_WildcardBeforeFirstGroup() + { + // "*" matches Slice1 for one Service namespace and Slice2 for the other, but both + // capture "Service", so slice "Service" has types in Slice1.Service and + // Slice2.Service, and it sits in MultipleSubnamespaces. To keep the two Service + // namespaces apart, capture the segment instead: "(*).(*)" + // (BuildUmlBySlicesNested_MultipleCaptureGroups). + return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "*.(*)"), Nested); + } + + [Fact] + public Task BuildUmlBySlicesNested_SamePartAfterDifferentPrefixes() + { + // The ".." lets "(*)" capture the last segment after any prefix, so slices are named + // after it alone. Service sits at MultipleSubnamespaces, as its types are in + // Slice1.Service and Slice2.Service, and Inner at Slice3. + return VerifySlices( + SliceRuleDefinition + .Slices() + .Matching("SlicesTestAssembly.MultipleSubnamespaces..(*)"), + Nested + ); + } + // --- One set of slices, drawn flat and nested ------------------------------------ [Fact] diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesFocusOn_SamePartAfterDifferentPrefixes.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesFocusOn_SamePartAfterDifferentPrefixes.verified.txt new file mode 100644 index 000000000..110ac56d0 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesFocusOn_SamePartAfterDifferentPrefixes.verified.txt @@ -0,0 +1,27 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { + package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { + package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { + [Slice Group1] as Slice_Group1 #99ffd1 + } + [Slice Slice3] as Slice_Slice3 #99ffd1 + } + [Slice Service] as Slice_Service + package "Slice1" as SlicesTestAssembly.MultipleSubnamespaces.Slice1 { + [Slice Slice1] as Slice_Slice1 + } + package "Slice2" as SlicesTestAssembly.MultipleSubnamespaces.Slice2 { + [Slice Slice2] as Slice_Slice2 + } +} + +[Slice_Group1] --|> [Slice_Slice3] +[Slice_Service] --|> [Slice_Slice3] +[Slice_Slice2] --|> [Slice_Slice3] +[Slice_Slice3] --|> [Slice_Slice1] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_LiteralBetweenCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_LiteralBetweenCaptureGroups.verified.txt new file mode 100644 index 000000000..a03244e56 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_LiteralBetweenCaptureGroups.verified.txt @@ -0,0 +1,11 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1.Inner" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1.Inner { + [Slice Slice3 - Inner] as Slice_Slice3___Inner +} + +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_MultipleCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_MultipleCaptureGroups.verified.txt index 9e1b7f74b..1aa7241db 100644 --- a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_MultipleCaptureGroups.verified.txt +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_MultipleCaptureGroups.verified.txt @@ -5,33 +5,21 @@ HIDE_STEREOTYPE() package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { - package "Slice1" as SlicesTestAssembly.MultipleSubnamespaces.Slice1 { - [Slice Slice1] as Slice_Slice1 - package "Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service { - [Slice Slice1.Service] as Slice_Slice1.Service - } + package "Slice1.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service { + [Slice Slice1 - Service] as Slice_Slice1___Service } - package "Slice2" as SlicesTestAssembly.MultipleSubnamespaces.Slice2 { - [Slice Slice2] as Slice_Slice2 - package "Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service { - [Slice Slice2.Service] as Slice_Slice2.Service - } + package "Slice2.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service { + [Slice Slice2 - Service] as Slice_Slice2___Service } package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { - [Slice Slice3] as Slice_Slice3 package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { - [Slice Slice3.Group1] as Slice_Slice3.Group1 + [Slice Slice3 - Group1] as Slice_Slice3___Group1 } package "Group2" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2 { - [Slice Slice3.Group2] as Slice_Slice3.Group2 + [Slice Slice3 - Group2] as Slice_Slice3___Group2 } } } -[Slice_Slice1] --|> [Slice_Slice2] -[Slice_Slice1.Service] --|> [Slice_Slice2.Service] -[Slice_Slice2] --|> [Slice_Slice3] -[Slice_Slice2.Service] --|> [Slice_Slice3] -[Slice_Slice3] --|> [Slice_Slice1] -[Slice_Slice3.Group1] --|> [Slice_Slice3] +[Slice_Slice1___Service] --|> [Slice_Slice2___Service] @enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups.verified.txt index 9e1b7f74b..6223615c5 100644 --- a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups.verified.txt +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups.verified.txt @@ -5,33 +5,22 @@ HIDE_STEREOTYPE() package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { - package "Slice1" as SlicesTestAssembly.MultipleSubnamespaces.Slice1 { - [Slice Slice1] as Slice_Slice1 - package "Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service { - [Slice Slice1.Service] as Slice_Slice1.Service - } + package "Slice1.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service { + [Slice Slice1 - Service] as Slice_Slice1___Service } - package "Slice2" as SlicesTestAssembly.MultipleSubnamespaces.Slice2 { - [Slice Slice2] as Slice_Slice2 - package "Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service { - [Slice Slice2.Service] as Slice_Slice2.Service - } + package "Slice2.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service { + [Slice Slice2 - Service] as Slice_Slice2___Service } package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { - [Slice Slice3] as Slice_Slice3 package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { - [Slice Slice3.Group1] as Slice_Slice3.Group1 + [Slice Slice3 - Group1] as Slice_Slice3___Group1 } package "Group2" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2 { - [Slice Slice3.Group2] as Slice_Slice3.Group2 + [Slice Slice3 - Group2] as Slice_Slice3___Group2 } + [Slice Slice3 - Inner] as Slice_Slice3___Inner } } -[Slice_Slice1] --|> [Slice_Slice2] -[Slice_Slice1.Service] --|> [Slice_Slice2.Service] -[Slice_Slice2] --|> [Slice_Slice3] -[Slice_Slice2.Service] --|> [Slice_Slice3] -[Slice_Slice3] --|> [Slice_Slice1] -[Slice_Slice3.Group1] --|> [Slice_Slice3] +[Slice_Slice1___Service] --|> [Slice_Slice2___Service] @enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_C4Style.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_C4Style.verified.txt new file mode 100644 index 000000000..39eab6f7b --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_C4Style.verified.txt @@ -0,0 +1,26 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +Boundary(SlicesTestAssembly.MultipleSubnamespaces, SlicesTestAssembly.MultipleSubnamespaces) { + Boundary(SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service, Slice1.Service) { + Container(Slice_Slice1___Service, Slice Slice1 - Service) + } + Boundary(SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service, Slice2.Service) { + Container(Slice_Slice2___Service, Slice Slice2 - Service) + } + Boundary(SlicesTestAssembly.MultipleSubnamespaces.Slice3, Slice3) { + Boundary(SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1, Group1) { + Container(Slice_Slice3___Group1, Slice Slice3 - Group1) + } + Boundary(SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2, Group2) { + Container(Slice_Slice3___Group2, Slice Slice3 - Group2) + } + Container(Slice_Slice3___Inner, Slice Slice3 - Inner) + } +} + +[Slice_Slice1___Service] --|> [Slice_Slice2___Service] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_LimitDependencies.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_LimitDependencies.verified.txt new file mode 100644 index 000000000..fa31c1ee3 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups_LimitDependencies.verified.txt @@ -0,0 +1,26 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { + package "Slice1.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service { + [Slice Slice1 - Service] as Slice_Slice1___Service + } + package "Slice2.Service" as SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service { + [Slice Slice2 - Service] as Slice_Slice2___Service + } + package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { + package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { + [Slice Slice3 - Group1] as Slice_Slice3___Group1 + } + package "Group2" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2 { + [Slice Slice3 - Group2] as Slice_Slice3___Group2 + } + [Slice Slice3 - Inner] as Slice_Slice3___Inner + } +} + +[Slice_Slice1___Service] --> [Slice_Slice2___Service] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_ParenthesisedAlternationAsFirstGroup.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_ParenthesisedAlternationAsFirstGroup.verified.txt new file mode 100644 index 000000000..9628a2619 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_ParenthesisedAlternationAsFirstGroup.verified.txt @@ -0,0 +1,17 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { + package "Slice1" as SlicesTestAssembly.MultipleSubnamespaces.Slice1 { + [Slice Slice1] as Slice_Slice1 + } + package "Slice2" as SlicesTestAssembly.MultipleSubnamespaces.Slice2 { + [Slice Slice2] as Slice_Slice2 + } +} + +[Slice_Slice1] --|> [Slice_Slice2] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SamePartAfterDifferentPrefixes.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SamePartAfterDifferentPrefixes.verified.txt new file mode 100644 index 000000000..e73935b2e --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SamePartAfterDifferentPrefixes.verified.txt @@ -0,0 +1,32 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { + package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { + package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { + [Slice Group1] as Slice_Group1 + } + package "Group2" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2 { + [Slice Group2] as Slice_Group2 + } + [Slice Inner] as Slice_Inner + [Slice Slice3] as Slice_Slice3 + } + [Slice Service] as Slice_Service + package "Slice1" as SlicesTestAssembly.MultipleSubnamespaces.Slice1 { + [Slice Slice1] as Slice_Slice1 + } + package "Slice2" as SlicesTestAssembly.MultipleSubnamespaces.Slice2 { + [Slice Slice2] as Slice_Slice2 + } +} + +[Slice_Group1] --|> [Slice_Slice3] +[Slice_Service] --|> [Slice_Slice3] +[Slice_Slice1] --|> [Slice_Slice2] +[Slice_Slice2] --|> [Slice_Slice3] +[Slice_Slice3] --|> [Slice_Slice1] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBeforeFirstGroup.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBeforeFirstGroup.verified.txt new file mode 100644 index 000000000..3b6968744 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBeforeFirstGroup.verified.txt @@ -0,0 +1,19 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces" as SlicesTestAssembly.MultipleSubnamespaces { + package "Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { + package "Group1" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { + [Slice Group1] as Slice_Group1 + } + package "Group2" as SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2 { + [Slice Group2] as Slice_Group2 + } + } + [Slice Service] as Slice_Service +} + +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBetweenCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBetweenCaptureGroups.verified.txt new file mode 100644 index 000000000..6c69253f1 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_WildcardBetweenCaptureGroups.verified.txt @@ -0,0 +1,11 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.MultipleSubnamespaces.Slice3" as SlicesTestAssembly.MultipleSubnamespaces.Slice3 { + [Slice Slice3 - Inner] as Slice_Slice3___Inner +} + +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_LimitDependencies_SamePartAfterDifferentPrefixes.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_LimitDependencies_SamePartAfterDifferentPrefixes.verified.txt new file mode 100644 index 000000000..0403c5fad --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_LimitDependencies_SamePartAfterDifferentPrefixes.verified.txt @@ -0,0 +1,17 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +[Slice Group1] as Slice_Group1 +[Slice Group2] as Slice_Group2 +[Slice Inner] as Slice_Inner +[Slice Service] as Slice_Service +[Slice Slice1] as Slice_Slice1 +[Slice Slice2] as Slice_Slice2 +[Slice Slice3] as Slice_Slice3 +[Slice_Slice1] --> [Slice_Slice2] +[Slice_Slice2] --> [Slice_Slice3] +[Slice_Slice3] --> [Slice_Slice1] +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_MultipleCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_MultipleCaptureGroups.verified.txt index 8e5c21f27..9dff3296d 100644 --- a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_MultipleCaptureGroups.verified.txt +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_MultipleCaptureGroups.verified.txt @@ -4,17 +4,9 @@ HIDE_STEREOTYPE() -[Slice Slice1] as Slice_Slice1 -[Slice Slice1.Service] as Slice_Slice1.Service -[Slice Slice2] as Slice_Slice2 -[Slice Slice2.Service] as Slice_Slice2.Service -[Slice Slice3] as Slice_Slice3 -[Slice Slice3.Group1] as Slice_Slice3.Group1 -[Slice Slice3.Group2] as Slice_Slice3.Group2 -[Slice_Slice1] --|> [Slice_Slice2] -[Slice_Slice1.Service] --|> [Slice_Slice2.Service] -[Slice_Slice2] --|> [Slice_Slice3] -[Slice_Slice2.Service] --|> [Slice_Slice3] -[Slice_Slice3] --|> [Slice_Slice1] -[Slice_Slice3.Group1] --|> [Slice_Slice3] +[Slice Slice1 - Service] as Slice_Slice1___Service +[Slice Slice2 - Service] as Slice_Slice2___Service +[Slice Slice3 - Group1] as Slice_Slice3___Group1 +[Slice Slice3 - Group2] as Slice_Slice3___Group2 +[Slice_Slice1___Service] --|> [Slice_Slice2___Service] @enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_NonContiguousCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_NonContiguousCaptureGroups.verified.txt index 8e5c21f27..4edda8cd4 100644 --- a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_NonContiguousCaptureGroups.verified.txt +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlices_NonContiguousCaptureGroups.verified.txt @@ -4,17 +4,10 @@ HIDE_STEREOTYPE() -[Slice Slice1] as Slice_Slice1 -[Slice Slice1.Service] as Slice_Slice1.Service -[Slice Slice2] as Slice_Slice2 -[Slice Slice2.Service] as Slice_Slice2.Service -[Slice Slice3] as Slice_Slice3 -[Slice Slice3.Group1] as Slice_Slice3.Group1 -[Slice Slice3.Group2] as Slice_Slice3.Group2 -[Slice_Slice1] --|> [Slice_Slice2] -[Slice_Slice1.Service] --|> [Slice_Slice2.Service] -[Slice_Slice2] --|> [Slice_Slice3] -[Slice_Slice2.Service] --|> [Slice_Slice3] -[Slice_Slice3] --|> [Slice_Slice1] -[Slice_Slice3.Group1] --|> [Slice_Slice3] +[Slice Slice1 - Service] as Slice_Slice1___Service +[Slice Slice2 - Service] as Slice_Slice2___Service +[Slice Slice3 - Group1] as Slice_Slice3___Group1 +[Slice Slice3 - Group2] as Slice_Slice3___Group2 +[Slice Slice3 - Inner] as Slice_Slice3___Inner +[Slice_Slice1___Service] --|> [Slice_Slice2___Service] @enduml diff --git a/ArchUnitNETTests/Fluent/FreezeTests.cs b/ArchUnitNETTests/Fluent/FreezeTests.cs index ff8e36c84..38561c55b 100644 --- a/ArchUnitNETTests/Fluent/FreezeTests.cs +++ b/ArchUnitNETTests/Fluent/FreezeTests.cs @@ -40,6 +40,14 @@ public class FreezeTests .Should() .NotDependOnEachOther(); + // Several capture groups, so the store keys its violations by slice names whose parts + // are joined with " - ". + private readonly IArchRule _frozenMultiGroupSliceRule = SliceRuleDefinition + .Slices() + .Matching("SlicesTestAssembly.MultipleSubnamespaces.(*).(*)") + .Should() + .NotDependOnEachOther(); + private readonly IArchRule _failingFrozenSliceRule = SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(*)..") @@ -52,6 +60,8 @@ public void PassFrozenRules() Freeze(_frozenRule).Check(Architecture); Freeze(_frozenRule2).Check(Architecture); Freeze(_frozenSliceRule).Check(StaticTestArchitectures.SlicesTestArchitecture); + Freeze(_frozenMultiGroupSliceRule) + .Check(StaticTestArchitectures.SlicesTestArchitecture); } [Fact] @@ -61,6 +71,8 @@ public void PassFrozenRulesUsingXmlViolationStore() Freeze(_frozenRule2, new XmlViolationStore()).Check(Architecture); Freeze(_frozenSliceRule, new XmlViolationStore()) .Check(StaticTestArchitectures.SlicesTestArchitecture); + Freeze(_frozenMultiGroupSliceRule, new XmlViolationStore()) + .Check(StaticTestArchitectures.SlicesTestArchitecture); } [Fact] diff --git a/ArchUnitNETTests/Fluent/Slices/MatchingWithPackagesTests.cs b/ArchUnitNETTests/Fluent/Slices/MatchingWithPackagesTests.cs index 7230472fc..83784c0bc 100644 --- a/ArchUnitNETTests/Fluent/Slices/MatchingWithPackagesTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/MatchingWithPackagesTests.cs @@ -30,6 +30,9 @@ public class MatchingWithPackagesTests Root + "(**)..", Root + "(*).(*)", Root + "(*)..(*)", + Root + "(*).*.(*)", + // A wildcard before the first group: the slices are grouped by the capture alone. + Root + "*.(*)", "..DuplicatePrefix.Sub.(**)", "(**).Service", "SlicesTestAssembly.SubnamespaceCircle.(**)", diff --git a/ArchUnitNETTests/Fluent/Slices/PatternValidationTests.cs b/ArchUnitNETTests/Fluent/Slices/PatternValidationTests.cs index bcc81a835..1b7258aab 100644 --- a/ArchUnitNETTests/Fluent/Slices/PatternValidationTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/PatternValidationTests.cs @@ -6,9 +6,8 @@ namespace ArchUnitNETTests.Fluent.Slices { /// - /// Pins which slice patterns are rejected and with what message. The patterns are - /// validated while the slices are enumerated rather than when the rule is defined, so - /// every case here has to consume the result before the exception surfaces. + /// Pins which slice patterns are rejected and with what message. Patterns are validated + /// when the rule is defined, so no architecture is needed to provoke the failure. /// public class PatternValidationTests { @@ -19,7 +18,7 @@ public class PatternValidationTests public void PatternWithoutCaptureGroupThrows(string pattern) { var ex = Assert.Throws(() => - SliceRuleDefinition.Slices().Matching(pattern).GetObjects(Architecture).ToList() + SliceRuleDefinition.Slices().Matching(pattern) ); Assert.Contains( "have to contain (*) or (**)", @@ -32,11 +31,7 @@ public void PatternWithoutCaptureGroupThrows(string pattern) public void PatternMixingSingleAndDoubleAsteriskThrows() { var ex = Assert.Throws(() => - SliceRuleDefinition - .Slices() - .Matching("Foo.(*).(**)") - .GetObjects(Architecture) - .ToList() + SliceRuleDefinition.Slices().Matching("Foo.(*).(**)") ); Assert.Contains( "can't contain both (*) and (**)", @@ -49,11 +44,7 @@ public void PatternMixingSingleAndDoubleAsteriskThrows() public void PatternWithRepeatedDoubleAsteriskThrows() { var ex = Assert.Throws(() => - SliceRuleDefinition - .Slices() - .Matching("Foo.(**).(**)") - .GetObjects(Architecture) - .ToList() + SliceRuleDefinition.Slices().Matching("Foo.(**).(**)") ); Assert.Contains( "can contain (**) only once", @@ -62,28 +53,19 @@ public void PatternWithRepeatedDoubleAsteriskThrows() ); } - // Prefix and postfix are matched independently against the full namespace - // string, without regard for segment boundaries. Crafting them so their required - // regions overlap makes the postfix check pass against the full namespace while the - // slice string computed after stripping the prefix no longer contains it -- this is - // the one real way to reach AssignFunc's "not clearly assignable" guard. - [Fact] - public void PatternWithOverlappingPrefixAndPostfix_NotClearlyAssignableThrows() + [Theory] + [InlineData("Foo...(*)", "more than two '.' in a row")] + [InlineData("Foo.**.(*)", "more than one '*' in a row")] + [InlineData("Foo.(..).Bar", "does not support capturing via (..)")] + [InlineData("Foo.[Bar].(*)", "without specifying any alternative via '|'")] + [InlineData("Foo.Bar|Baz.(*)", "only supports '|' inside of '[]' or '()'")] + [InlineData("Foo.((*)).Bar", "does not support nesting")] + public void MalformedPatternThrows(string pattern, string expectedMessage) { - const string prefix = "SlicesTestAssembly.DotDotSeman"; - const string postfix = "Assembly.DotDotSemantics.Single"; var ex = Assert.Throws(() => - SliceRuleDefinition - .Slices() - .Matching(prefix + "(**)" + postfix) - .GetObjects(Architecture) - .ToList() - ); - Assert.Contains( - "is not clearly assignable", - ex.Message, - StringComparison.OrdinalIgnoreCase + SliceRuleDefinition.Slices().Matching(pattern) ); + Assert.Contains(expectedMessage, ex.Message, StringComparison.OrdinalIgnoreCase); } [Theory] @@ -92,6 +74,9 @@ public void PatternWithOverlappingPrefixAndPostfix_NotClearlyAssignableThrows() [InlineData("Foo.(**)..")] [InlineData("Foo.(*)..")] [InlineData("Foo.(*).(*)")] + [InlineData("Foo.[Bar|Baz].(*)")] + [InlineData("Foo.(Bar|Baz)")] + [InlineData("Foo.(Bar|Baz).(*)")] public void ValidPatternDoesNotThrow(string pattern) { SliceRuleDefinition.Slices().Matching(pattern).GetObjects(Architecture).ToList(); diff --git a/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs b/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs index d7ae5f03c..e2c29b8c7 100644 --- a/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs @@ -183,6 +183,29 @@ public void SliceIdentifier_PartsContainingTheSeparator_AreADifferentSlice() Assert.False(SliceIdentifier.Comparer.Equals(onePart, twoParts)); } + [Theory] + [InlineData("App.(*)", "App.Orders", "Slice Orders")] + [InlineData("App.(**)", "App.Orders.Web", "Slice Orders.Web")] + [InlineData("App.(*)..", "App.Orders.Web", "Slice Orders")] + [InlineData("App.(*).(*)", "App.Orders.Http", "Slice Orders - Http")] + [InlineData("App.(*).Service.(*)", "App.Orders.Service.Http", "Slice Orders - Http")] + [InlineData("App.(*)..(*)", "App.Orders.Web.Http", "Slice Orders - Http")] + [InlineData("App.(*).*.(*)", "App.Orders.Web.Http", "Slice Orders - Http")] + public void SliceIdentifier_Description_OfAMatch_IsSliceFollowedByTheCaptures( + string pattern, + string nameSpace, + string expected + ) + { + var match = SliceRuleInitializer.ConvertPatternToRegex(pattern).Match(nameSpace); + + Assert.True(match.Success); + Assert.Equal( + expected, + SliceIdentifier.Of(SliceRuleInitializer.Captures(match)).Description + ); + } + [Fact] public void SliceIdentifier_ToString_IsTheDescription() { @@ -208,21 +231,15 @@ public void ViolationKey_IsTheDescription() [Fact] public void ViolationKey_OfSeveralParts_IsTheDescription() { - // Patterns capture a single part, so a custom assignment builds several. - var creator = new SliceRuleCreator(); - creator.SetSliceAssignment( - new SliceAssignment( - type => SliceIdentifier.Of(new[] { "Custom", type.Name }), - "by name" - ) - ); - - var keys = new SlicesShould(creator) + var keys = SliceRuleDefinition + .Slices() + .Matching(Root + "(*).(*)") + .Should() .NotDependOnEachOther() .Evaluate(Architecture) .Select(result => result.EvaluatedObjectIdentifier.Identifier); - Assert.Contains("Slice Custom - Slice1Class", keys); + Assert.Contains("Slice Slice1 - Service", keys); } // --- SlicedBy ------------------------------------------------------------------ diff --git a/ArchUnitNETTests/Fluent/Slices/SliceRuleCombinationTests.cs b/ArchUnitNETTests/Fluent/Slices/SliceRuleCombinationTests.cs index b62f9bdd4..742c4d373 100644 --- a/ArchUnitNETTests/Fluent/Slices/SliceRuleCombinationTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/SliceRuleCombinationTests.cs @@ -38,10 +38,9 @@ public class SliceRuleCombinationTests private static SliceRule CyclicSliceRule => SliceRuleDefinition.Slices().Matching(Root + "(**)").Should().BeFreeOfCycles(); - // "(**).." on MultipleSubnamespaces is acyclic too, but only because it folds away the - // types that would form the cycle (see SlicesTests.SubnamespaceCycleDetection_ - // FoldedIntoParent_MissesCycle) -- a fixture that is genuinely acyclic keeps this test - // stable once that fold is reimplemented. + // MultipleSubnamespaces has a cycle under every grouping (see + // SlicesTests.CycleDetectionTest), so the acyclic operand comes from SubnamespaceCircle, + // whose "(**)" slices depend on each other without forming a cycle. private static SliceRule AcyclicSliceRule => SliceRuleDefinition .Slices() diff --git a/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs b/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs index 67290d3ba..c650e3a44 100644 --- a/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using ArchUnitNET.Domain; using ArchUnitNET.Fluent.Slices; using ArchUnitNETTests.AssemblyTestHelper; using Xunit; @@ -19,11 +21,21 @@ public void CycleDetectionTest() .Should() .BeFreeOfCycles() .AssertOnlyViolations(helper); + // Slice3 depends back on Slice1, so the cycle survives every regrouping of the whole + // namespace -- including "(**)..", which used to skip the top-level classes and + // therefore missed the edge that closes the loop. SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(**)..") .Should() .BeFreeOfCycles() + .AssertOnlyViolations(helper); + // Restricting the slices to the "Service" sub-namespaces drops that edge again. + SliceRuleDefinition + .Slices() + .Matching("SlicesTestAssembly.MultipleSubnamespaces.(**).Service..") + .Should() + .BeFreeOfCycles() .AssertNoViolations(helper); } @@ -31,7 +43,7 @@ public void CycleDetectionTest() public void MatchingTest() { Assert.Equal( - 9, + 3, SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(*)") @@ -47,7 +59,7 @@ public void MatchingTest() .Count() ); Assert.Equal( - 9, + 3, SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(*)..") @@ -55,7 +67,7 @@ public void MatchingTest() .Count() ); Assert.Equal( - 3, + 9, SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(**)..") @@ -63,7 +75,7 @@ public void MatchingTest() .Count() ); Assert.Equal( - 4, + 2, SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.Slice3.(*)") @@ -106,7 +118,7 @@ public void NotDependOnEachOtherTest() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(*)..") .Should() .NotDependOnEachOther() - .AssertAnyViolations(helper); + .AssertOnlyViolations(helper); SliceRuleDefinition .Slices() .Matching("SlicesTestAssembly.MultipleSubnamespaces.(**)..") @@ -133,14 +145,14 @@ public void DirectCycleDetectionTest() } } - // None of these patterns folds Slice2.Inner into Slice2, so Slice1 -> Slice2 and - // Slice2.Inner -> Slice1 stay dependencies between three distinct slices and there is - // genuinely no cycle to find. + // Neither of these patterns folds Slice2.Inner into Slice2 -- "(*)" ignores it altogether + // and "(**)" gives it a slice of its own -- so Slice1 -> Slice2 and Slice2.Inner -> Slice1 + // stay dependencies between distinct slices and there is genuinely no cycle to find. [Fact] public void SubnamespaceCycleDetectionTest() { var helper = new SlicesAssemblyTestHelper().WithoutSnapshot(); - foreach (var pattern in new[] { "(*)", "(*)..", "(**)" }) + foreach (var pattern in new[] { "(*)", "(**)" }) { SliceRuleDefinition .Slices() @@ -151,27 +163,54 @@ public void SubnamespaceCycleDetectionTest() } } - // See: https://github.com/TNG/ArchUnitNET/issues/208 -- "(**).." is the one pattern that - // folds Slice2.Inner into Slice2, which should surface the cycle - // Slice1 -> Slice2 -> (Slice2.Inner) -> Slice1. It currently does not: the fold drops the - // types sitting directly in the captured namespace (see - // Matching_DoubleAsteriskDotDot_DropsTypesDirectlyInCapturedNamespace), so Slice1Class - // never makes it into a slice and the cycle has no starting edge. Pinned as-is; this - // assertion is expected to flip once the matcher is reimplemented. + // See: https://github.com/TNG/ArchUnitNET/issues/208 -- "(*).." is the one pattern that + // folds Slice2.Inner into Slice2, which surfaces the cycle + // Slice1 -> Slice2 -> (Slice2.Inner) -> Slice1. [Fact] - public void SubnamespaceCycleDetection_FoldedIntoParent_MissesCycle() + public void SubnamespaceCycleDetection_FoldedIntoParent_DetectsCycle() { var helper = new SlicesAssemblyTestHelper().WithoutSnapshot(); SliceRuleDefinition .Slices() - .Matching("SlicesTestAssembly.SubnamespaceCircle.(**)..") + .Matching("SlicesTestAssembly.SubnamespaceCircle.(*)..") .Should() .BeFreeOfCycles() - .AssertNoViolations(helper); + .AssertOnlyViolations(helper); + } + + [Fact] + public void Matching_SingleAsterisk_CapturesExactlyOneSegment() + { + // A namespace deeper than the pattern does not match at all, so the types below + // "Slice1.Service" and friends are left out entirely. + Assert.Equal( + new[] { "Slice Slice1", "Slice Slice2", "Slice Slice3" }, + Descriptions(Root + "(*)") + ); + } + + [Fact] + public void Matching_SingleAsteriskDotDot_FoldsSubnamespacesIntoParent() + { + Assert.Equal( + new[] { "Slice Slice1", "Slice Slice2", "Slice Slice3" }, + Descriptions(Root + "(*)..") + ); + + // Same names as "(*)", but the trailing ".." pulls the deeper types in as well. + var slices = SliceRuleDefinition + .Slices() + .Matching(Root + "(*)..") + .GetObjects(StaticTestArchitectures.SlicesTestArchitecture) + .ToList(); + Assert.Equal( + 5, + slices.Single(slice => slice.Description == "Slice Slice3").Types.Count() + ); } [Fact] - public void Matching_SingleAsterisk_CapturesEveryDepth() + public void Matching_DoubleAsterisk_CapturesEveryDepth() { Assert.Equal( new[] @@ -186,89 +225,100 @@ public void Matching_SingleAsterisk_CapturesEveryDepth() "Slice Slice3.Group2", "Slice Slice3.Group2.Inner", }, - Descriptions(Root + "(*)") + Descriptions(Root + "(**)") ); } [Fact] - public void Matching_SingleAsteriskDotDot_CapturesEveryDepth() + public void Matching_DoubleAsteriskDotDot_IsRedundant() { - Assert.Equal(Descriptions(Root + "(*)"), Descriptions(Root + "(*)..")); + // "(**)" is already greedy, so a trailing ".." has nothing left to skip. + Assert.Equal(Descriptions(Root + "(**)"), Descriptions(Root + "(**)..")); } [Fact] - public void Matching_DoubleAsteriskDotDot_FoldsSubnamespacesIntoParent() + public void Matching_TrailingLiteralAfterCapture_MatchesOnlyThatLiteral() { Assert.Equal( - new[] { "Slice Slice1", "Slice Slice2", "Slice Slice3" }, - Descriptions(Root + "(**)..") + new[] { "Slice Slice1", "Slice Slice2" }, + Descriptions(Root + "(**).Service..") ); } - /// - /// The descriptions above only say which slices exist; this pins what ends up inside - /// them, which is where "(**).." is at its most surprising. Folding keeps the types - /// from the sub-namespaces but drops the ones sitting directly in the captured - /// namespace, so Slice1Class/Slice2Class/Slice3Class are in no slice at all. That is - /// what makes the NotDependOnEachOther snapshots report Slice2 and Slice3 as - /// depending on nothing, and what hides the cycle in - /// . - /// [Fact] - public void Matching_DoubleAsteriskDotDot_DropsTypesDirectlyInCapturedNamespace() + public void Matching_Alternation_MatchesEitherAlternative() { - var types = SliceRuleDefinition - .Slices() - .Matching(Root + "(**)..") - .GetObjects(StaticTestArchitectures.SlicesTestArchitecture) - .ToDictionary( - slice => slice.Description, - slice => - slice - .Types.Select(type => type.FullName) - .OrderBy(name => name, StringComparer.Ordinal) - .ToArray() - ); - - Assert.Equal(new[] { Root + "Slice1.Service.Service1Class" }, types["Slice Slice1"]); - Assert.Equal(new[] { Root + "Slice2.Service.Service2Class" }, types["Slice Slice2"]); Assert.Equal( new[] { - Root + "Slice3.Group1.Group1Class", - Root + "Slice3.Group1.Inner.Inner1Class", - Root + "Slice3.Group2.Group2Class", - Root + "Slice3.Group2.Inner.Inner2Class", + "Slice Slice1", + "Slice Slice2", + "Slice Slice3.Group1", + "Slice Slice3.Group2", }, - types["Slice Slice3"] + Descriptions(Root + "(**).[Service|Inner]") ); } + // "(A|B)" is a capture group like "(*)": the alternative that matched is part of the + // slice's identity and its name, whereas "[A|B]" only selects. [Fact] - public void Matching_TrailingLiteralAfterCapture_MatchesNothing() + public void Matching_ParenthesisedAlternation_IsACaptureGroup() { - Assert.Empty(Descriptions(Root + "(**).Service..")); + Assert.Equal( + new[] { "Slice Slice1", "Slice Slice2" }, + Descriptions(Root + "(Slice1|Slice2)..") + ); + Assert.Equal( + new[] { "Slice Slice1 - Service", "Slice Slice2 - Service" }, + Descriptions(Root + "(Slice1|Slice2).(*)") + ); + Assert.Equal( + new[] + { + "Slice Slice1 - Service", + "Slice Slice2 - Service", + "Slice Slice3 - Group1", + }, + Descriptions(Root + "(*).(Service|Group1)") + ); } [Fact] - public void Matching_Alternation_MatchesNothing() + public void Matching_WildcardBeforeFirstGroup_GroupsByTheCaptureAlone() { - Assert.Empty(Descriptions(Root + "(**).[Service|Inner]")); + // "*" matches Slice1 for one Service namespace and Slice2 for the other; only the + // capture identifies a slice, so both are one slice. + var slices = Slices(Root + "*.(*)"); + + Assert.Equal( + new[] { "Slice Group1", "Slice Group2", "Slice Service" }, + Descriptions(Root + "*.(*)") + ); + Assert.Equal( + new[] + { + Root + "Slice1.Service.Service1Class", + Root + "Slice2.Service.Service2Class", + }, + slices + .Single(slice => slice.Description == "Slice Service") + .Types.Select(type => type.FullName) + .OrderBy(name => name, StringComparer.Ordinal) + ); } [Fact] - public void Matching_LeadingDotDot_CapturesEveryDepth() + public void Matching_LeadingDotDot_SkipsWholeLeadingSegments() { Assert.Equal(Descriptions(Root + "(*)"), Descriptions("..MultipleSubnamespaces.(*)")); } // The fixture's namespace contains "DuplicatePrefix.Sub" twice // (SlicesTestAssembly.DuplicatePrefix.Sub.DuplicatePrefix.Sub), so a leading ".." pattern - // for that prefix has two candidate starting positions to strip from. This pins that - // AssignFunc's IndexOf (leftmost match) picks the first occurrence, leaving the second - // "DuplicatePrefix.Sub" in the description. With an absolute (non-"..") prefix, the - // prefix can only ever be found at index 0, so that variant of this test would pass even - // if IndexOf were replaced by a hardcoded 0. + // for that prefix has two candidate positions to match at. The ".." is greedy, but + // skipping as far as the second occurrence leaves nothing for the capture group, so the + // match backtracks to the first: the captured slice is the *second* "DuplicatePrefix.Sub". [Fact] public void DuplicatePrefixSegment_CapturesAfterTheFirstOccurrenceOfThePrefix() { @@ -285,39 +335,55 @@ public void DuplicatePrefixSegment_CapturesAfterTheFirstOccurrenceOfThePrefix() // --- ".." semantics ------------------------------------------------------------- [Fact] - public void DotDot_BetweenCaptureGroups_CapturesEveryDepth() + public void DotDot_BetweenCaptureGroups_SkipsWholeSegments() { + // "Single" has nothing for the second group to capture and "AlphaService" is a single + // segment, so neither matches; "Outer.Mid.Inner" has its middle segment skipped. Assert.Equal( - new[] - { - "Slice Alpha.Service", - "Slice AlphaService", - "Slice Outer.Inner", - "Slice Outer.Mid.Inner", - "Slice Single", - }, + new[] { "Slice Alpha - Service", "Slice Outer - Inner" }, Descriptions(DotDot + "(*)..(*)") ); } [Fact] - public void DotDot_BeforeLiteral_CapturesEveryDepth() + public void Matching_LiteralBetweenCaptureGroups_IsNotPartOfTheName() + { + Assert.Equal(new[] { "Slice Outer - Inner" }, Descriptions(DotDot + "(*).Mid.(*)")); + } + + [Fact] + public void Matching_WildcardBetweenCaptureGroups_IsNotPartOfTheName() { + Assert.Equal(new[] { "Slice Outer - Inner" }, Descriptions(DotDot + "(*).*.(*)")); + } + + [Fact] + public void Matching_TrailingPatternText_IsNotPartOfTheName() + { + // Everything after the last capture group selects types rather than naming them. + Assert.Equal(new[] { "Slice Outer" }, Descriptions(DotDot + "(*).Mid.Inner")); + } + + [Fact] + public void Matching_Parts_AreTheCapturesInGroupOrder() + { + var slice = Assert.Single(Slices(DotDot + "(*).Mid.(*)")); + + Assert.Equal(new[] { "Outer", "Inner" }, slice.Identifier.Parts); Assert.Equal( - new[] - { - "Slice Alpha.Service", - "Slice AlphaService", - "Slice Outer.Inner", - "Slice Outer.Mid.Inner", - "Slice Single", - }, - Descriptions(DotDot + "(*)..Service") + new[] { "Outer.Mid.Inner" }, + Assert.Single(Slices(DotDot + "(**)", "Outer.Mid.Inner")).Identifier.Parts ); } [Fact] - public void DotDot_BeforeLiteral_GivesAlphaServiceAndAlphaDotServiceDistinctSlices() + public void DotDot_BeforeLiteral_MatchesOnlyWholeSegments() + { + Assert.Equal(new[] { "Slice Alpha" }, Descriptions(DotDot + "(*)..Service")); + } + + [Fact] + public void DotDot_BeforeLiteral_DoesNotSplitWithinASegment() { var slices = SliceRuleDefinition .Slices() @@ -325,14 +391,47 @@ public void DotDot_BeforeLiteral_GivesAlphaServiceAndAlphaDotServiceDistinctSlic .GetObjects(StaticTestArchitectures.SlicesTestArchitecture) .ToList(); - // "AlphaService" is one segment and "Alpha.Service" is two, but that distinction is - // never actually evaluated: the "..Service" postfix is discarded (see - // AlphaServiceClass.cs), so both namespaces just get their own unfolded slice, each - // holding exactly the one type it's made of. This is not evidence the matcher tells - // the two shapes apart -- it would look identical if it ignored segment boundaries - // entirely. - Assert.Single(slices.Single(slice => slice.Description == "Slice AlphaService").Types); - Assert.Single(slices.Single(slice => slice.Description == "Slice Alpha.Service").Types); + // Only "Alpha.Service" matches. "AlphaService" is one segment and must not be split + // into "Alpha" + "Service". + var alpha = Assert.Single(slices); + Assert.Equal("Slice Alpha", alpha.Description); + Assert.Equal( + "SlicesTestAssembly.DotDotSemantics.Alpha.Service.AlphaServiceSegmentClass", + Assert.Single(alpha.Types).FullName + ); + } + + // --- identity across rules ------------------------------------------------------- + + [Fact] + public void OneCaptureAndTwoCapturesOfTheSameNamespace_AreDifferentSlices() + { + // "(**)" captures "Slice1.Service" as one value and "(*).(*)" as two. A slice is + // identified by its captures, so the two rules produce different slices, and their + // names say so. + var oneCapture = Slices(Root + "(**)", "Slice1.Service").Single(); + var twoCaptures = Slices(Root + "(*).(*)", "Slice1.Service").Single(); + + Assert.Equal(new[] { "Slice1.Service" }, oneCapture.Identifier.Parts); + Assert.Equal(new[] { "Slice1", "Service" }, twoCaptures.Identifier.Parts); + Assert.Equal("Slice Slice1.Service", oneCapture.Description); + Assert.Equal("Slice Slice1 - Service", twoCaptures.Description); + Assert.NotEqual(oneCapture, twoCaptures); + } + + [Fact] + public void SameCapturesWithDifferentSeparators_AreTheSameSlice() + { + // All three patterns capture "Outer" and "Inner" from "Outer.Mid.Inner"; what the + // pattern says between the groups only selects types. + var literal = Assert.Single(Slices(DotDot + "(*).Mid.(*)")); + var wildcard = Assert.Single(Slices(DotDot + "(*).*.(*)")); + var dotDot = Slices(DotDot + "(*)..(*)") + .Single(slice => slice.Description == "Slice Outer - Inner"); + + Assert.Equal(literal, dotDot); + Assert.Equal(literal, wildcard); + Assert.Equal(literal.Description, dotDot.Description); } // --- failure messages ----------------------------------------------------------- @@ -411,6 +510,30 @@ public Task NotDependOnEachOther_ReturnsDescriptiveDependencyMessage() return helper.AssertSnapshotMatches(); } + private static List Slices(string pattern) + { + return SliceRuleDefinition + .Slices() + .Matching(pattern) + .GetObjects(StaticTestArchitectures.SlicesTestArchitecture) + .ToList(); + } + + /// + /// The slices of the pattern that hold a type of the given namespace, relative to the + /// pattern's fixture. + /// + private static List Slices(string pattern, string relativeNamespace) + { + return Slices(pattern) + .Where(slice => + slice.Types.Any(type => + type.Namespace.FullName.EndsWith("." + relativeNamespace) + ) + ) + .ToList(); + } + private static string[] Descriptions(string pattern) { return SliceRuleDefinition diff --git a/ArchUnitNETTests/Fluent/Slices/Snapshots/SlicesTests.NotDependOnEachOther_ReturnsDescriptiveDependencyMessage.verified.txt b/ArchUnitNETTests/Fluent/Slices/Snapshots/SlicesTests.NotDependOnEachOther_ReturnsDescriptiveDependencyMessage.verified.txt index 56687bc1e..054362c57 100644 --- a/ArchUnitNETTests/Fluent/Slices/Snapshots/SlicesTests.NotDependOnEachOther_ReturnsDescriptiveDependencyMessage.verified.txt +++ b/ArchUnitNETTests/Fluent/Slices/Snapshots/SlicesTests.NotDependOnEachOther_ReturnsDescriptiveDependencyMessage.verified.txt @@ -2,18 +2,65 @@ Result: False Description: Slice Slice1 does depend on other slices: Slice Slice1 -> Slice Slice2 + SlicesTestAssembly.MultipleSubnamespaces.Slice1.Slice1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice2.Slice2Class + +Result: False +Description: Slice Slice1.Service does depend on other slices: +Slice Slice1.Service -> Slice Slice2.Service SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service.Service1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service.Service2Class +Result: False +Description: Slice Slice2 does depend on other slices: +Slice Slice2 -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice2.Slice2Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + +Result: False +Description: Slice Slice2.Service does depend on other slices: +Slice Slice2.Service -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service.Service2Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + +Result: False +Description: Slice Slice3 does depend on other slices: +Slice Slice3 -> Slice Slice1 + SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice1.Slice1Class + +Result: False +Description: Slice Slice3.Group1 does depend on other slices: +Slice Slice3.Group1 -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1.Group1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + Result: True -Description: Slice Slice2 does not depend on another slice. +Description: Slice Slice3.Group1.Inner does not depend on another slice. Result: True -Description: Slice Slice3 does not depend on another slice. +Description: Slice Slice3.Group2 does not depend on another slice. +Result: True +Description: Slice Slice3.Group2.Inner does not depend on another slice. Message: "Slices matching "SlicesTestAssembly.MultipleSubnamespaces.(**).." should not depend on each other" failed: Slice Slice1 does depend on other slices: Slice Slice1 -> Slice Slice2 + SlicesTestAssembly.MultipleSubnamespaces.Slice1.Slice1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice2.Slice2Class + + Slice Slice1.Service does depend on other slices: +Slice Slice1.Service -> Slice Slice2.Service SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service.Service1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service.Service2Class + Slice Slice2 does depend on other slices: +Slice Slice2 -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice2.Slice2Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + + Slice Slice2.Service does depend on other slices: +Slice Slice2.Service -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service.Service2Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + + Slice Slice3 does depend on other slices: +Slice Slice3 -> Slice Slice1 + SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice1.Slice1Class + + Slice Slice3.Group1 does depend on other slices: +Slice Slice3.Group1 -> Slice Slice3 + SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1.Group1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice3.Slice3Class + diff --git a/TestAssemblies/SlicesTestAssembly/DotDotSemantics/AlphaService/AlphaServiceClass.cs b/TestAssemblies/SlicesTestAssembly/DotDotSemantics/AlphaService/AlphaServiceClass.cs index feedab11d..bf35a30e3 100644 --- a/TestAssemblies/SlicesTestAssembly/DotDotSemantics/AlphaService/AlphaServiceClass.cs +++ b/TestAssemblies/SlicesTestAssembly/DotDotSemantics/AlphaService/AlphaServiceClass.cs @@ -1,7 +1,6 @@ namespace SlicesTestAssembly.DotDotSemantics.AlphaService; -// A single segment that ends with the literal "Service". Parse() discards everything after the -// first "(*" for single-asterisk patterns, so "DotDotSemantics.(*)..Service" never actually -// applies the "..Service" postfix: this namespace matches it just the same as Alpha/Service below, -// even though "AlphaService" is one segment and not "Alpha" + "Service". +// A single segment that ends with the literal "Service", unlike Alpha/Service below, which really +// is two segments. "DotDotSemantics.(*)..Service" tells the two apart only if ".." matches whole +// segments. public class AlphaServiceClass { } diff --git a/documentation/docs/guide.md b/documentation/docs/guide.md index 1d3df4d90..8e6ba43c4 100644 --- a/documentation/docs/guide.md +++ b/documentation/docs/guide.md @@ -184,9 +184,27 @@ To use the `Slices()` entry point directly, add: using static ArchUnitNET.Fluent.Slices.SliceRuleDefinition; ``` +#### Pattern syntax +| Token | Meaning | +|----------|---------| +| `(*)` | Captures exactly one namespace segment. | +| `(**)` | Captures one or more segments. | +| `(A\|B)` | Captures one of the listed alternatives. | +| `*` | Matches one segment without capturing it. | +| `..` | Matches zero or more segments. | +| `[A\|B]` | Matches one of the listed alternatives. | + +A pattern must contain at least one capture group, may not mix `(*)` and `(**)`, and may use `(**)` +at most once. `Matching` throws an `ArgumentException` otherwise -- when the rule is defined, not when +it is checked. Types whose namespace does not match are left out entirely. + #### How slices are named A slice is identified by **what its capture groups captured**, in group order. Two types end up in -the same slice exactly when their groups captured the same values. +the same slice exactly when their groups captured the same values. The pattern text between, before +and after the groups only selects which types belong to a slice: `"App.(*).(*)"`, +`"App.(*).Service.(*)"` and `"App.(*)..(*)"` all put the types they match in `Orders` and `Http` +into the same slice, while `"App.(**)"` over `App.Orders.Http` captures the single value +`Orders.Http`, which is a different slice. A slice is named after its captures: `Slice ` followed by the captured values joined with ` - `. The name never reads as a namespace, and it is what failure messages and diagrams show. @@ -195,10 +213,19 @@ name never reads as a namespace, and it is what failure messages and diagrams sh |---|---|---| | `App.(*)` | `App.Orders` | `Slice Orders` | | `App.(**)` | `App.Orders.Web` | `Slice Orders.Web` | +| `App.(*)..` | `App.Orders.Web` | `Slice Orders` | +| `App.(*).(*)` | `App.Orders.Http` | `Slice Orders - Http` | +| `App.(*).Service.(*)` | `App.Orders.Service.Http` | `Slice Orders - Http` | +| `App.(*)..(*)` | `App.Orders.Web.Http` | `Slice Orders - Http` | +| `App.(*).*.(*)` | `App.Orders.Web.Http` | `Slice Orders - Http` | Frozen violations of a slice rule (see `FreezingArchRule`) are stored by the slice name, e.g. `Slice Orders`. +With `NestSlicesInPackages`, each slice is drawn under its name inside the package of the deepest +namespace all its types share (see [5.2](#52-structured-diagram-dependencies)). `Slice Orders - Http` +of `App.(*)..(*)` sits in `Orders` when its types are in `Orders.Web.Http` and `Orders.Api.Http`. + #### Cycle-free rule ![Cycle](img/cycle.svg) ```cs @@ -302,7 +329,7 @@ PlantUmlDefinition.ComponentDiagram().WithDependenciesFromSlices(sliceRule.GetOb ``` ### 5.4 Small slices -In order not to display all slices and all occurrences, you can use a single asterisk in the pattern. One star is one slice deep. You cannot mix single (*) and double (**) asterisks in a pattern. +In order not to display all slices and all occurrences, you can use a single asterisk in the pattern. One star captures one namespace segment. You cannot mix single `(*)` and double `(**)` asterisks in one pattern, but you can repeat `(*)` -- `"ArchUnitNET.(*).(*)"` captures two segments and draws `Slice Fluent - Syntax` inside the `ArchUnitNET.Fluent.Syntax` namespace. #### 5.4.1 ArchUnitNET.(\*) ![Diagram](diagrams/archUnitNet_one.svg)