diff --git a/ArchUnitNET/Domain/PlantUml/Export/GenerationOptions.cs b/ArchUnitNET/Domain/PlantUml/Export/GenerationOptions.cs index 44348f502..1b995aa7b 100644 --- a/ArchUnitNET/Domain/PlantUml/Export/GenerationOptions.cs +++ b/ArchUnitNET/Domain/PlantUml/Export/GenerationOptions.cs @@ -10,5 +10,14 @@ public class GenerationOptions public bool IncludeNodesWithoutDependencies { get; set; } = true; public bool LimitDependencies { get; set; } = false; public bool C4Style { get; set; } = false; + + /// + /// Whether slice diagrams nest each slice in package blocks along the namespace its + /// types matched, instead of drawing every slice on its own under its name. A slice whose + /// types matched different namespaces before the first capture group, or that does not + /// come from a slice pattern, is drawn on its own either way. Slices of the obsolete + /// MatchingWithPackages are always nested. + /// + public bool NestSlicesInPackages { get; set; } = false; } } diff --git a/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs b/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs index 7432a2354..fc758aa6f 100644 --- a/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs +++ b/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs @@ -15,6 +15,12 @@ public class PlantUmlFileBuilder private List _sliceList = new List(); + /// + /// The name each slice is drawn under, and the root package it is nested in, if any. + /// + private Dictionary _diagramNames = + new Dictionary(); + public string AsString(RenderOptions renderOptions = null) { if (renderOptions == null) @@ -122,13 +128,21 @@ public PlantUmlFileBuilder WithDependenciesFrom( return this; } + /// + /// Draws the given slices as components, with an arrow from one slice to another wherever + /// one of its types depends on one of the other's. + /// + /// The slices to draw, e.g. from GivenSlices.GetObjects. + /// Filters and layout options; the defaults if null. + /// With , slices from a slice pattern + /// are nested in package blocks along the namespace they matched. + /// Two slices would be drawn under the same name. public PlantUmlFileBuilder WithDependenciesFrom( IEnumerable slices, GenerationOptions generationOptions = null ) { _sliceList = slices.Distinct().ToList(); - RemovePatternInappropriateSlices(); var nodes = new Dictionary(); if (generationOptions == null) @@ -136,20 +150,23 @@ public PlantUmlFileBuilder WithDependenciesFrom( generationOptions = new GenerationOptions(); } + AssignDiagramNames(generationOptions.NestSlicesInPackages); + RemovePatternInappropriateSlices(); + foreach (var slice in _sliceList) { var sliceIsPackage = IsPackage(slice); var dependencyTargets = SelectDependencies( slice, _sliceList, - target => target.Description, + NameOf, target => target.Types, generationOptions ); if (!sliceIsPackage) { - var slc = new PlantUmlSlice(slice.Description, slice.NameSpace); + var slc = new PlantUmlSlice(NameOf(slice), NameSpaceOf(slice)); if (generationOptions.C4Style) { slc.UseS4Style(); @@ -163,8 +180,8 @@ public PlantUmlFileBuilder WithDependenciesFrom( { _dependencies.AddRange( dependencyTargets.Select(target => new PlantUmlDependency( - slice.Description, - target.Description, + NameOf(slice), + NameOf(target), DependencyType.PackageToOne )) ); @@ -173,21 +190,21 @@ public PlantUmlFileBuilder WithDependenciesFrom( { _dependencies.AddRange( dependencyTargets.Select(target => new PlantUmlDependency( - slice.Description, - target.Description, + NameOf(slice), + NameOf(target), DependencyType.OneToOne )) ); } } - else if (slice.ContainsNamespace()) + else if (NameSpaceOf(slice) != null) { if (sliceIsPackage) { _dependencies.AddRange( dependencyTargets.Select(target => new PlantUmlDependency( - slice.Description, - target.Description, + NameOf(slice), + NameOf(target), DependencyType.PackageToPackageIfSameParentNamespace )) ); @@ -196,15 +213,15 @@ public PlantUmlFileBuilder WithDependenciesFrom( { var dt = dependencyTargets .Select(target => new PlantUmlDependency( - slice.Description, - target.Description, + NameOf(slice), + NameOf(target), DependencyType.OneToOneIfSameParentNamespace )) .Where(dependency => dependency.OriginCountOfDots() < dependency.TargetCountOfDots() || _sliceList.All(slc => - slc.Description == dependency.Target - || !slc.Description.Contains(dependency.Target) + NameOf(slc) == dependency.Target + || !NameOf(slc).Contains(dependency.Target) ) ); _dependencies.AddRange(dt); @@ -214,8 +231,8 @@ public PlantUmlFileBuilder WithDependenciesFrom( { _dependencies.AddRange( dependencyTargets.Select(target => new PlantUmlDependency( - slice.Description, - target.Description, + NameOf(slice), + NameOf(target), DependencyType.OneToOneCompact )) ); @@ -224,7 +241,7 @@ public PlantUmlFileBuilder WithDependenciesFrom( if (!generationOptions.IncludeNodesWithoutDependencies) { - RemoveNodesNotContainedInDependencies(nodes, slice => slice.Description); + RemoveNodesNotContainedInDependencies(nodes, NameOf); } if (!generationOptions.LimitDependencies) @@ -328,12 +345,25 @@ var parent in parentNamespaces.Where(parentName => return this; } + /// + /// Draws only the dependencies from and to the slices whose diagram name contains + /// , and colors those slices. + /// + /// The slices to draw, e.g. from GivenSlices.GetObjects. + /// The text to focus on. When slices are nested in packages, their + /// diagram names are fully qualified, so this has to be as well. + /// Only + /// is read; the defaults if null. + /// The package is empty, a single dot or not contained + /// in any slice's diagram name, or two slices would be drawn under the same name. public PlantUmlFileBuilder WithDependenciesFromFocusOn( IEnumerable slices, - string package + string package, + GenerationOptions generationOptions = null ) { _sliceList = slices.Distinct().ToList(); + AssignDiagramNames(generationOptions?.NestSlicesInPackages ?? false); switch (package) { @@ -351,7 +381,7 @@ string package RemovePatternInappropriateSlices(package); var existPackage = _sliceList.Any(slice => - (slice.NameSpace + slice.Description).Contains(package) + (NameSpaceOf(slice) + NameOf(slice)).Contains(package) ); if (!existPackage) { @@ -361,7 +391,7 @@ string package } var nodes = new Dictionary(); - var generationOptions = new GenerationOptions(); + var defaultOptions = new GenerationOptions(); foreach (var slice in _sliceList) { @@ -369,17 +399,17 @@ string package var dependencyTargets = SelectDependencies( slice, _sliceList, - target => target.Description, + NameOf, target => target.Types, - generationOptions + defaultOptions ); if (sliceIsPackage) { _dependencies.AddRange( dependencyTargets.Select(target => new PlantUmlDependency( - slice.Description, - target.Description, + NameOf(slice), + NameOf(target), DependencyType.PackageToOne )) ); @@ -388,8 +418,8 @@ string package { _dependencies.AddRange( dependencyTargets.Select(target => new PlantUmlDependency( - slice.Description, - target.Description, + NameOf(slice), + NameOf(target), DependencyType.OneToOne )) ); @@ -399,20 +429,20 @@ string package { nodes.Add( slice, - slice.Description.Contains(package) - ? new PlantUmlSlice(slice.Description, slice.NameSpace, "99ffd1") - : new PlantUmlSlice(slice.Description, slice.NameSpace) + NameOf(slice).Contains(package) + ? new PlantUmlSlice(NameOf(slice), NameSpaceOf(slice), "99ffd1") + : new PlantUmlSlice(NameOf(slice), NameSpaceOf(slice)) ); } - else if (!slice.Description.Contains(package)) + else if (!NameOf(slice).Contains(package)) { - nodes.Add(slice, new PlantUmlSlice(slice.Description + ".", slice.NameSpace)); + nodes.Add(slice, new PlantUmlSlice(NameOf(slice) + ".", NameSpaceOf(slice))); } } - if (!generationOptions.IncludeNodesWithoutDependencies) + if (!defaultOptions.IncludeNodesWithoutDependencies) { - RemoveNodesNotContainedInDependencies(nodes, slice => slice.Description); + RemoveNodesNotContainedInDependencies(nodes, NameOf); } RemoveDuplicateDependenciesWhenShowingPackages(); @@ -433,8 +463,8 @@ string package for (var i = nodes.Count - 1; i >= 0; i--) { var nodeExist = _dependencies.Any(dep => - nodes.ElementAt(i).Key.Description == dep.Origin - || nodes.ElementAt(i).Key.Description == dep.Target + NameOf(nodes.ElementAt(i).Key) == dep.Origin + || NameOf(nodes.ElementAt(i).Key) == dep.Target ); if (!nodeExist) { @@ -445,10 +475,10 @@ string package _diagram.AddElements(nodes.Values); var tempList = _sliceList - .Where(slice => IsPackage(slice) && slice.Description.Contains(package)) + .Where(slice => IsPackage(slice) && NameOf(slice).Contains(package)) .Select(slice => new PlantUmlSlice( - slice.Description + ".", - slice.NameSpace, + NameOf(slice) + ".", + NameSpaceOf(slice), "99ffd1" )) .ToList(); @@ -517,7 +547,7 @@ private void RemovePatternInappropriateSlices(string thatContainsThisString = nu if ( thatContainsThisString != null && !_sliceList.Any(slice => - (slice.NameSpace + slice.Description).Contains(thatContainsThisString) + (NameSpaceOf(slice) + NameOf(slice)).Contains(thatContainsThisString) ) ) { @@ -530,14 +560,15 @@ private void RemovePatternInappropriateSlices(string thatContainsThisString = nu if ( thatContainsThisString != null - && !(_sliceList[i].Description).Contains(thatContainsThisString) + && !NameOf(_sliceList[i]).Contains(thatContainsThisString) ) { continue; } + // 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 == '.'); - dots -= (_sliceList[i].NameSpace ?? string.Empty).Count(c => c == '.'); if (dots >= _sliceList[i].CountOfAsteriskInPattern) { @@ -565,7 +596,7 @@ private void RemoveDuplicatedArrowsIfExist() private void RemoveDuplicateDependenciesWhenShowingPackages() { - if (_sliceList.Any(slice => !slice.ContainsNamespace())) + if (_sliceList.Any(slice => NameSpaceOf(slice) == null)) { return; } @@ -574,8 +605,8 @@ private void RemoveDuplicateDependenciesWhenShowingPackages() { if ( _sliceList.Any(slice => - slice.Description.Contains(_dependencies[j].Target) - && slice.Description != _dependencies[j].Target + NameOf(slice).Contains(_dependencies[j].Target) + && NameOf(slice) != _dependencies[j].Target ) ) { @@ -686,9 +717,79 @@ private void ReplaceCirclesWithAppropriateDependencyType() } } + /// + /// Decides for every slice the name it is drawn under and the root package it is nested + /// in, if any. + /// + /// + /// + /// The slice exporter works out the layout from these names alone: which slice is the + /// package of another, which arrows to merge, how deep LimitDependencies goes. A + /// nested slice is therefore drawn under its qualified name, the namespace its types + /// matched before the first capture group followed by its + /// , e.g. "App.Orders.Http", with that namespace + /// as its root package. Every other slice is drawn under its description, without a + /// package. + /// + /// + /// Qualified names of one pattern are distinct, but a description can coincide with a + /// qualified name. Drawing two slices as one node would be wrong, so that is rejected. + /// + /// + /// + /// Whether the options ask for nesting; slices of MatchingWithPackages are nested + /// regardless. + /// + private void AssignDiagramNames(bool nestSlicesInPackages) + { + var nest = nestSlicesInPackages || _sliceList.Any(slice => slice.NestByDefault); + _diagramNames = new Dictionary(); + var slicesByName = new Dictionary(); + foreach (var slice in _sliceList) + { + var nameSpace = slice.DiagramNamespace; + var diagramPath = slice.Identifier.DiagramPath; + (string Name, string NameSpace) diagramName = + nest && nameSpace != null && diagramPath != null + ? (nameSpace + diagramPath, nameSpace == "" ? null : nameSpace) + : (slice.Description, null); + + if (slicesByName.TryGetValue(diagramName.Name, out var other)) + { + throw new ArgumentException( + "The slices \"" + + other.Identifier.Identifier + + "\" and \"" + + slice.Identifier.Identifier + + "\" would both be drawn as \"" + + diagramName.Name + + "\"." + ); + } + + slicesByName.Add(diagramName.Name, slice); + _diagramNames.Add(slice, diagramName); + } + } + + /// The name the diagram draws a slice under; see . + private string NameOf(Slice slice) + { + return _diagramNames[slice].Name; + } + + /// + /// The root package a slice is nested in, with a trailing ".", or null if it is + /// drawn on its own. + /// + private string NameSpaceOf(Slice slice) + { + return _diagramNames[slice].NameSpace; + } + private bool IsPackage(Slice slice) { - if (!slice.ContainsNamespace()) + if (NameSpaceOf(slice) == null) { return false; } @@ -696,8 +797,8 @@ private bool IsPackage(Slice slice) for (var i = _sliceList.Count - 1; i >= 0; i--) { if ( - _sliceList[i].Description != slice.Description - & _sliceList[i].Description.StartsWith(slice.Description) + NameOf(_sliceList[i]) != NameOf(slice) + & NameOf(_sliceList[i]).StartsWith(NameOf(slice)) ) { return true; diff --git a/ArchUnitNET/Domain/Slice.cs b/ArchUnitNET/Domain/Slice.cs index 658a06596..d2261b35f 100644 --- a/ArchUnitNET/Domain/Slice.cs +++ b/ArchUnitNET/Domain/Slice.cs @@ -27,10 +27,25 @@ public Slice(SliceIdentifier identifier, IEnumerable types) public string Description => Identifier.Description; - [CanBeNull] - public string NameSpace => Identifier.NameSpace; public int? CountOfAsteriskInPattern => Identifier.CountOfAsteriskInPattern; + /// + /// The namespace a diagram nests this slice under when it nests slices in packages: the + /// part of the namespace that the pattern matched before the first capture group, if + /// it is the same for every type in the slice, which may be empty. null if it + /// differs between the types, or if the slice does not come from a slice pattern; a + /// diagram then draws the slice on its own. It takes no part in the slice's identity. + /// + [CanBeNull] + internal string DiagramNamespace { get; set; } + + /// + /// Whether diagrams nest this slice in packages even if + /// is not set, which + /// is the case for slices of the obsolete MatchingWithPackages. + /// + internal bool NestByDefault { get; set; } + protected bool Equals(Slice other) { return Equals(Identifier, other.Identifier); @@ -65,10 +80,5 @@ public override string ToString() { return Description; } - - public bool ContainsNamespace() - { - return NameSpace != null; - } } } diff --git a/ArchUnitNET/Domain/SliceIdentifier.cs b/ArchUnitNET/Domain/SliceIdentifier.cs index e84a4a539..8dc528989 100644 --- a/ArchUnitNET/Domain/SliceIdentifier.cs +++ b/ArchUnitNET/Domain/SliceIdentifier.cs @@ -11,28 +11,62 @@ private SliceIdentifier( string identifier, bool ignored, int? countOfAsteriskInPattern = null, - string nameSpace = null + string matchedPrefix = null, + string diagramPath = null ) : base(identifier) { Ignored = ignored; CountOfAsteriskInPattern = countOfAsteriskInPattern; - NameSpace = nameSpace; + MatchedPrefix = matchedPrefix; + DiagramPath = diagramPath; } public string Description => Identifier; + /// + /// The part of the type's namespace the pattern matched before the first capture group, + /// e.g. "App." for "App.(*)" over "App.Orders", or an empty string if the pattern starts + /// with a group; null if the identifier does not come from a slice pattern. Only + /// used to nest the slice in diagrams; it takes no part in the slice's identity. + /// [CanBeNull] - public readonly string NameSpace; + internal readonly string MatchedPrefix; + + /// + /// The path a diagram nests the slice along below : the part of + /// the namespace the pattern captured, e.g. "Orders.Web" for "App.(**)" over + /// "App.Orders.Web". null if the identifier does not come from a slice pattern. It + /// takes no part in the slice's identity. + /// + [CanBeNull] + internal readonly string DiagramPath; + public readonly int? CountOfAsteriskInPattern; - public static SliceIdentifier Of( + public static SliceIdentifier Of(string identifier, int? countOfAsteriskInPattern = null) + { + return new SliceIdentifier(identifier, false, countOfAsteriskInPattern); + } + + /// + /// Creates the identifier a slice pattern assigns to a type, together with where the + /// pattern matched, which diagrams use to nest the slice. + /// + internal static SliceIdentifier OfMatch( string identifier, - int? countOfAsteriskInPattern = null, - string nameSpace = null + int? countOfAsteriskInPattern, + string matchedPrefix, + string diagramPath ) { - return new SliceIdentifier(identifier, false, countOfAsteriskInPattern, nameSpace); + return new SliceIdentifier( + identifier, + false, + countOfAsteriskInPattern, + matchedPrefix, + diagramPath + ); } public static SliceIdentifier Ignore() diff --git a/ArchUnitNET/Fluent/PlantUml/PlantUmlFluentComponentDiagramInitializer.cs b/ArchUnitNET/Fluent/PlantUml/PlantUmlFluentComponentDiagramInitializer.cs index 2bc3f92bc..93d65edc3 100644 --- a/ArchUnitNET/Fluent/PlantUml/PlantUmlFluentComponentDiagramInitializer.cs +++ b/ArchUnitNET/Fluent/PlantUml/PlantUmlFluentComponentDiagramInitializer.cs @@ -65,14 +65,26 @@ public GivenPlantUmlFluentComponentDiagram WithDependenciesFromSlices( return new GivenPlantUmlFluentComponentDiagram(_fluentComponentDiagramCreator); } + /// + /// Draws only the dependencies from and to the slices whose diagram name contains + /// ; see + /// . + /// + /// The slices to draw. + /// The text to focus on, fully qualified when slices are + /// nested in packages. + /// Only + /// is read. public GivenPlantUmlFluentComponentDiagram WithDependenciesFromSlices( IEnumerable slices, - string focusOnPackage + string focusOnPackage, + GenerationOptions generationOptions = null ) { _fluentComponentDiagramCreator.Builder.WithDependenciesFromFocusOn( slices, - focusOnPackage + focusOnPackage, + generationOptions ); _fluentComponentDiagramCreator.AddToDescription("with dependencies from slices"); return new GivenPlantUmlFluentComponentDiagram(_fluentComponentDiagramCreator); diff --git a/ArchUnitNET/Fluent/Slices/SliceAssignment.cs b/ArchUnitNET/Fluent/Slices/SliceAssignment.cs index db5bdf1c4..9fdfe8aa7 100644 --- a/ArchUnitNET/Fluent/Slices/SliceAssignment.cs +++ b/ArchUnitNET/Fluent/Slices/SliceAssignment.cs @@ -12,21 +12,57 @@ public class SliceAssignment : IHasDescription public SliceAssignment( Func assignIdentifierFunc, string description + ) + : this(assignIdentifierFunc, description, false) { } + + internal SliceAssignment( + Func assignIdentifierFunc, + string description, + bool nestByDefault ) { _assignIdentifierFunc = assignIdentifierFunc; Description = description; + _nestByDefault = nestByDefault; } + /// + /// Whether diagrams nest the slices in packages even if + /// is not set; + /// only for the obsolete MatchingWithPackages. + /// + private readonly bool _nestByDefault; + public string Description { get; } public IEnumerable Apply(IEnumerable types) { - return types.GroupBy( - _assignIdentifierFunc, - (identifier, enumerable) => new Slice(identifier, enumerable), - SliceIdentifier.Comparer - ); + return types + .Select(type => (Type: type, Identifier: _assignIdentifierFunc(type))) + .GroupBy( + assigned => assigned.Identifier, + (identifier, assigned) => CreateSlice(identifier, assigned.ToList()), + SliceIdentifier.Comparer + ); + } + + private Slice CreateSlice( + SliceIdentifier identifier, + List<(IType Type, SliceIdentifier Identifier)> assigned + ) + { + // The types of one slice can have matched different prefixes, e.g. with a leading + // ".."; such a slice has no single namespace to be nested under. + var prefixes = assigned + .Select(typeAndIdentifier => typeAndIdentifier.Identifier.MatchedPrefix) + .Distinct() + .ToList(); + var types = assigned.Select(typeAndIdentifier => typeAndIdentifier.Type).ToList(); + return new Slice(identifier, types) + { + DiagramNamespace = prefixes.Count == 1 ? prefixes[0] : null, + NestByDefault = _nestByDefault, + }; } } } diff --git a/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs b/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs index 228de6820..1482a6602 100644 --- a/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs +++ b/ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs @@ -21,29 +21,36 @@ public SliceRuleInitializer(SliceRuleCreator ruleCreator) /// public GivenSlices Matching(string pattern) { - _ruleCreator.SetSliceAssignment( - new SliceAssignment( - t => - { - var parseResult = Parse(pattern); - return AssignFunc(t, parseResult.pattern, parseResult.asterisk); - }, - "matching \"" + pattern + "\"" - ) - ); - return new GivenSlices(_ruleCreator); + return Matching(pattern, false); } + /// + /// Creates the same slices as , which PlantUML diagrams nest in + /// package blocks even without + /// . + /// + /// The namespace pattern, as for . + [Obsolete( + "Use Matching(pattern) and draw the slices with " + + "new GenerationOptions { NestSlicesInPackages = true }. MatchingWithPackages " + + "creates the same slices as Matching and will be removed in a future release." + )] public GivenSlices MatchingWithPackages(string pattern) + { + return Matching(pattern, true); + } + + private GivenSlices Matching(string pattern, bool nestByDefault) { _ruleCreator.SetSliceAssignment( new SliceAssignment( t => { var parseResult = Parse(pattern); - return AssignFunc(t, parseResult.pattern, parseResult.asterisk, true); + return AssignFunc(t, parseResult.pattern, parseResult.asterisk); }, - "matching \"" + pattern + "\"" + "matching \"" + pattern + "\"", + nestByDefault ) ); return new GivenSlices(_ruleCreator); @@ -87,8 +94,7 @@ private static (string pattern, int? asterisk) Parse(string pattern) private static SliceIdentifier AssignFunc( IType type, string pattern, - int? countOfSingleAsterisk, - bool fullName = false + int? countOfSingleAsterisk ) { var indexOfAsteriskInPattern = pattern.IndexOf("(*", StringComparison.Ordinal); @@ -133,14 +139,14 @@ private static SliceIdentifier AssignFunc( } var sliceString = namespc; + var matchedPrefix = ""; if (slicePrefix != "") { - sliceString = namespc - .Substring( - namespc.IndexOf(slicePrefix, StringComparison.Ordinal) + slicePrefix.Length - ) - .TrimStart('.'); + var prefixEnd = + namespc.IndexOf(slicePrefix, StringComparison.Ordinal) + slicePrefix.Length; + matchedPrefix = namespc.Substring(0, prefixEnd); + sliceString = namespc.Substring(prefixEnd).TrimStart('.'); } if (!sliceString.Contains(slicePostfix)) @@ -161,15 +167,14 @@ private static SliceIdentifier AssignFunc( ); } - // An empty prefix means the pattern starts with its capture group; there is no - // namespace to nest the slice under, so report none rather than an empty one. - return fullName - ? SliceIdentifier.Of( - slicePrefix + sliceString, - countOfSingleAsterisk, - slicePrefix == "" ? null : slicePrefix - ) - : SliceIdentifier.Of(sliceString, countOfSingleAsterisk); + // What the pattern captured is both what identifies the slice and the path a + // diagram nests it along. + return SliceIdentifier.OfMatch( + sliceString, + countOfSingleAsterisk, + matchedPrefix, + sliceString + ); } } } diff --git a/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs b/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs index 824bece2b..d3f13c11b 100644 --- a/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs +++ b/ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs @@ -22,12 +22,15 @@ public class PlantUmlSliceDiagramTests private const string Root = "SlicesTestAssembly.MultipleSubnamespaces."; + private static GenerationOptions Nested => + new GenerationOptions { NestSlicesInPackages = true }; + // --- The asterisk count decides which slices the exporter renders --------------- // // 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 Description, less dots in NameSpace) reaches + // a slice once its depth (dots in its Description) reaches // Slice.CountOfAsteriskInPattern: // // (*) count 1 -> 3 of 9 slices rendered @@ -54,7 +57,7 @@ public Task BuildUmlBySlices_DoubleAsterisk() // The matcher discards everything after the first "(*", so "(*).(*)" and "(*)..(*)" // are indistinguishable to it and the next two snapshots are byte-identical -- as are - // their MatchingWithPackages counterparts below. That identity is the point: it is + // 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. @@ -76,64 +79,60 @@ public Task BuildUmlBySlices_NonContiguousCaptureGroups() } [Fact] - public Task BuildUmlBySlicesMatchingWithPackages_SingleAsterisk() + public Task BuildUmlBySlicesNested_SingleAsterisk() { - return VerifySlices(SliceRuleDefinition.Slices().MatchingWithPackages(Root + "(*)")); + return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "(*)"), Nested); } [Fact] - public Task BuildUmlBySlicesMatchingWithPackages_DoubleAsterisk() + public Task BuildUmlBySlicesNested_DoubleAsterisk() { - return VerifySlices(SliceRuleDefinition.Slices().MatchingWithPackages(Root + "(**)")); + return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "(**)"), Nested); } [Fact] - public Task BuildUmlBySlicesMatchingWithPackages_MultipleCaptureGroups() + public Task BuildUmlBySlicesNested_MultipleCaptureGroups() { - return VerifySlices( - SliceRuleDefinition.Slices().MatchingWithPackages(Root + "(*).(*)") - ); + return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "(*).(*)"), Nested); } [Fact] - public Task BuildUmlBySlicesMatchingWithPackages_NonContiguousCaptureGroups() + public Task BuildUmlBySlicesNested_NonContiguousCaptureGroups() { - return VerifySlices( - SliceRuleDefinition.Slices().MatchingWithPackages(Root + "(*)..(*)") - ); + return VerifySlices(SliceRuleDefinition.Slices().Matching(Root + "(*)..(*)"), Nested); } [Fact] - public Task BuildUmlBySlicesMatchingWithPackages_CaptureGroupAtStart() + public Task BuildUmlBySlicesNested_CaptureGroupAtStart() { // Nothing precedes the capture group, so there is no namespace to nest the slices // under and they are drawn flat, like those of Matching. - return VerifySlices(SliceRuleDefinition.Slices().MatchingWithPackages("(**).Service")); + return VerifySlices(SliceRuleDefinition.Slices().Matching("(**).Service"), Nested); } [Fact] - public Task BuildUmlBySlicesMatchingWithPackages_LimitDependencies() + public Task BuildUmlBySlicesNested_LimitDependencies() { return VerifySlices( - SliceRuleDefinition.Slices().MatchingWithPackages(Root + "(**)"), - new GenerationOptions { LimitDependencies = true } + SliceRuleDefinition.Slices().Matching(Root + "(**)"), + new GenerationOptions { NestSlicesInPackages = true, LimitDependencies = true } ); } [Fact] - public Task BuildUmlBySlicesMatchingWithPackages_C4Style() + public Task BuildUmlBySlicesNested_C4Style() { return VerifySlices( - SliceRuleDefinition.Slices().MatchingWithPackages(Root + "(**)"), - new GenerationOptions { C4Style = true } + SliceRuleDefinition.Slices().Matching(Root + "(**)"), + new GenerationOptions { NestSlicesInPackages = true, C4Style = true } ); } [Fact] public Task BuildUmlBySlices_C4Style() { - // Slices produced by Matching (as opposed to MatchingWithPackages) have no namespace - // prefix, so this exercises PlantUmlSlice.BuildStringC4Style's no-namespace branch. + // Slices that are not nested have no namespace prefix, so this exercises + // PlantUmlSlice.BuildStringC4Style's no-namespace branch. return VerifySlices( SliceRuleDefinition.Slices().Matching(Root + "(**)"), new GenerationOptions { C4Style = true } @@ -143,11 +142,9 @@ public Task BuildUmlBySlices_C4Style() [Fact] public Task BuildUmlBySlicesFocusOn() { - var slices = SortedSlices( - SliceRuleDefinition.Slices().MatchingWithPackages(Root + "(**)") - ); + var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)")); var uml = new PlantUmlFileBuilder() - .WithDependenciesFromFocusOn(slices, Root + "Slice1") + .WithDependenciesFromFocusOn(slices, Root + "Slice1", Nested) .AsString(); return VerifyUml(uml); } @@ -155,10 +152,11 @@ public Task BuildUmlBySlicesFocusOn() [Fact] public Task BuildUmlBySlicesFocusOn_Matching_ColorsFocusedSlices() { - // Slices produced by Matching have no namespace, so the focused ones take + // Slices that are not nested have no namespace, so the focused ones take // PlantUmlSlice.BuildString's namespace-less branch with a colour set -- the only // path on which the exporter renders "[Slice1] #99ffd1" rather than a package. - // Their Description carries no Root prefix either, hence the bare focus string. + // They are drawn under their description, which carries no Root prefix, hence the + // bare focus string. var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)")); var uml = new PlantUmlFileBuilder() .WithDependenciesFromFocusOn(slices, "Slice1") @@ -179,14 +177,12 @@ public Task BuildUmlBySlices_DirectCircle_RendersCircleDependency() } [Fact] - public Task BuildUmlBySlicesMatchingWithPackages_DirectCircle() + public Task BuildUmlBySlicesNested_DirectCircle() { var slices = SortedSlices( - SliceRuleDefinition - .Slices() - .MatchingWithPackages("SlicesTestAssembly.DirectCircle.(*)") + SliceRuleDefinition.Slices().Matching("SlicesTestAssembly.DirectCircle.(*)") ); - var uml = new PlantUmlFileBuilder().WithDependenciesFrom(slices).AsString(); + var uml = new PlantUmlFileBuilder().WithDependenciesFrom(slices, Nested).AsString(); return VerifyUml(uml); } @@ -199,19 +195,20 @@ public Task BuildUmlBySlicesMatchingWithPackages_DirectCircle() private const string SameNamedRoot = "SlicesTestAssembly.SameNamedSubnamespaces."; [Fact] - public Task BuildUmlBySlicesMatchingWithPackages_SameNamedPackages() + public Task BuildUmlBySlicesNested_SameNamedPackages() { return VerifySlices( - SliceRuleDefinition.Slices().MatchingWithPackages(SameNamedRoot + "(**)") + SliceRuleDefinition.Slices().Matching(SameNamedRoot + "(**)"), + Nested ); } [Fact] - public Task BuildUmlBySlicesMatchingWithPackages_SameNamedPackages_C4Style() + public Task BuildUmlBySlicesNested_SameNamedPackages_C4Style() { return VerifySlices( - SliceRuleDefinition.Slices().MatchingWithPackages(SameNamedRoot + "(**)"), - new GenerationOptions { C4Style = true } + SliceRuleDefinition.Slices().Matching(SameNamedRoot + "(**)"), + new GenerationOptions { NestSlicesInPackages = true, C4Style = true } ); } @@ -272,10 +269,10 @@ public void BuildUmlBySlicesFocusOn_EmptyPackage_Throws() { var slices = SliceRuleDefinition .Slices() - .MatchingWithPackages(Root + "(**)") + .Matching(Root + "(**)") .GetObjects(Architecture); var ex = Assert.Throws(() => - new PlantUmlFileBuilder().WithDependenciesFromFocusOn(slices, "") + new PlantUmlFileBuilder().WithDependenciesFromFocusOn(slices, "", Nested) ); Assert.Equal("Package can't be empty", ex.Message); } @@ -285,10 +282,10 @@ public void BuildUmlBySlicesFocusOn_DotOnlyPackage_Throws() { var slices = SliceRuleDefinition .Slices() - .MatchingWithPackages(Root + "(**)") + .Matching(Root + "(**)") .GetObjects(Architecture); var ex = Assert.Throws(() => - new PlantUmlFileBuilder().WithDependenciesFromFocusOn(slices, ".") + new PlantUmlFileBuilder().WithDependenciesFromFocusOn(slices, ".", Nested) ); Assert.Equal("Package can't contain a single dot only", ex.Message); } @@ -301,10 +298,10 @@ public void BuildUmlBySlicesFocusOn_UnknownPackage_Throws() // focus string that no slice contains, on the way to this exception. var slices = SliceRuleDefinition .Slices() - .MatchingWithPackages(Root + "(*)") + .Matching(Root + "(*)") .GetObjects(Architecture); var ex = Assert.Throws(() => - new PlantUmlFileBuilder().WithDependenciesFromFocusOn(slices, "NotAPackage") + new PlantUmlFileBuilder().WithDependenciesFromFocusOn(slices, "NotAPackage", Nested) ); Assert.Contains("is not contained in this slice", ex.Message); } @@ -314,18 +311,18 @@ public void BuildUmlBySlicesFocusOn_TrailingDotPackage_IsTrimmed() { var slicesWithTrailingDot = SliceRuleDefinition .Slices() - .MatchingWithPackages(Root + "(**)") + .Matching(Root + "(**)") .GetObjects(Architecture); var umlWithTrailingDot = new PlantUmlFileBuilder() - .WithDependenciesFromFocusOn(slicesWithTrailingDot, Root + "Slice1.") + .WithDependenciesFromFocusOn(slicesWithTrailingDot, Root + "Slice1.", Nested) .AsString(); var slicesWithoutTrailingDot = SliceRuleDefinition .Slices() - .MatchingWithPackages(Root + "(**)") + .Matching(Root + "(**)") .GetObjects(Architecture); var umlWithoutTrailingDot = new PlantUmlFileBuilder() - .WithDependenciesFromFocusOn(slicesWithoutTrailingDot, Root + "Slice1") + .WithDependenciesFromFocusOn(slicesWithoutTrailingDot, Root + "Slice1", Nested) .AsString(); Assert.Equal(umlWithoutTrailingDot, umlWithTrailingDot); @@ -340,15 +337,68 @@ public Task BuildUmlBySlicesFocusOn_SingleAsteriskPattern_SkipsSlicesWithoutFocu // doesn't contain it (e.g. "Slice1") skip the pattern-appropriateness check via // RemovePatternInappropriateSlices' per-slice "continue", while "Slice3" itself is // shallow enough to survive that check and keep the package reachable. - var slices = SortedSlices( - SliceRuleDefinition.Slices().MatchingWithPackages(Root + "(*)") - ); + var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(*)")); var uml = new PlantUmlFileBuilder() - .WithDependenciesFromFocusOn(slices, Root + "Slice3") + .WithDependenciesFromFocusOn(slices, Root + "Slice3", Nested) .AsString(); return VerifyUml(uml); } + // --- Where nested slices are rooted ---------------------------------------------- + // + // A nested slice's root package is the namespace its types matched before the first + // capture group, so it has to come from the match, not from the pattern text. + + [Fact] + public Task BuildUmlBySlicesNested_DuplicatePrefixSegment() + { + // The leading ".." matches "SlicesTestAssembly.DuplicatePrefix.Sub.", so that whole + // namespace is the root package, although "DuplicatePrefix.Sub" occurs in it twice. + return VerifySlices( + SliceRuleDefinition.Slices().Matching("..DuplicatePrefix.Sub.(**)"), + Nested + ); + } + + // --- One set of slices, drawn flat and nested ------------------------------------ + + [Fact] + public void BuildUmlBySlices_SameSlicesDrawnFlatAndNested() + { + var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)")); + + var flat = new PlantUmlFileBuilder().WithDependenciesFrom(slices).AsString(); + var nested = new PlantUmlFileBuilder().WithDependenciesFrom(slices, Nested).AsString(); + + Assert.DoesNotContain("package", flat); + Assert.Contains("[Slice1.Service]", flat); + Assert.Contains("package \"SlicesTestAssembly.MultipleSubnamespaces\"", nested); + Assert.DoesNotContain("[Slice1.Service]", nested); + } + + // --- Two slices may not share a node ---------------------------------------------- + + [Fact] + public void BuildUmlBySlices_SameDiagramName_Throws() + { + // A slice that is not nested is drawn under its description, which here is the + // qualified name a nested slice is drawn under. + var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)")) + .Concat( + new[] { new Slice(SliceIdentifier.Of(Root + "Slice1"), Array.Empty()) } + ) + .ToList(); + + var ex = Assert.Throws(() => + new PlantUmlFileBuilder().WithDependenciesFrom(slices, Nested) + ); + Assert.Equal( + "The slices \"Slice1\" and \"SlicesTestAssembly.MultipleSubnamespaces.Slice1\" " + + "would both be drawn as \"SlicesTestAssembly.MultipleSubnamespaces.Slice1\".", + ex.Message + ); + } + /// /// GetObjects returns slices in Mono.Cecil's type-table order, which is deterministic /// per build but not contractually stable across Cecil or runtime upgrades. Sorting by diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_C4Style.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_C4Style.verified.txt similarity index 100% rename from ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_C4Style.verified.txt rename to ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_C4Style.verified.txt diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_CaptureGroupAtStart.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_CaptureGroupAtStart.verified.txt similarity index 100% rename from ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_CaptureGroupAtStart.verified.txt rename to ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_CaptureGroupAtStart.verified.txt diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_DirectCircle.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_DirectCircle.verified.txt similarity index 100% rename from ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_DirectCircle.verified.txt rename to ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_DirectCircle.verified.txt diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_DoubleAsterisk.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_DoubleAsterisk.verified.txt similarity index 100% rename from ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_DoubleAsterisk.verified.txt rename to ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_DoubleAsterisk.verified.txt diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_DuplicatePrefixSegment.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_DuplicatePrefixSegment.verified.txt new file mode 100644 index 000000000..3e8672334 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_DuplicatePrefixSegment.verified.txt @@ -0,0 +1,13 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +package "SlicesTestAssembly.DuplicatePrefix.Sub" as SlicesTestAssembly.DuplicatePrefix.Sub { + package "DuplicatePrefix" as SlicesTestAssembly.DuplicatePrefix.Sub.DuplicatePrefix { + [Sub] as SlicesTestAssembly.DuplicatePrefix.Sub.DuplicatePrefix.Sub + } +} + +@enduml diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_LimitDependencies.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_LimitDependencies.verified.txt similarity index 100% rename from ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_LimitDependencies.verified.txt rename to ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_LimitDependencies.verified.txt diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_MultipleCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_MultipleCaptureGroups.verified.txt similarity index 100% rename from ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_MultipleCaptureGroups.verified.txt rename to ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_MultipleCaptureGroups.verified.txt diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_NonContiguousCaptureGroups.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups.verified.txt similarity index 100% rename from ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_NonContiguousCaptureGroups.verified.txt rename to ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_NonContiguousCaptureGroups.verified.txt diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_SameNamedPackages.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SameNamedPackages.verified.txt similarity index 100% rename from ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_SameNamedPackages.verified.txt rename to ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SameNamedPackages.verified.txt diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_SameNamedPackages_C4Style.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SameNamedPackages_C4Style.verified.txt similarity index 100% rename from ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_SameNamedPackages_C4Style.verified.txt rename to ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SameNamedPackages_C4Style.verified.txt diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_SingleAsterisk.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SingleAsterisk.verified.txt similarity index 100% rename from ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesMatchingWithPackages_SingleAsterisk.verified.txt rename to ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlSliceDiagramTests.BuildUmlBySlicesNested_SingleAsterisk.verified.txt diff --git a/ArchUnitNETTests/Fluent/PlantUml/PlantUmlFluentComponentDiagramTests.cs b/ArchUnitNETTests/Fluent/PlantUml/PlantUmlFluentComponentDiagramTests.cs index 033fd5683..a18ebfeb7 100644 --- a/ArchUnitNETTests/Fluent/PlantUml/PlantUmlFluentComponentDiagramTests.cs +++ b/ArchUnitNETTests/Fluent/PlantUml/PlantUmlFluentComponentDiagramTests.cs @@ -45,11 +45,14 @@ public void ComponentDiagramFromSlicesTest() Assert.NotEmpty(uml2); var arch1 = new ArchLoader().LoadAssembly(typeof(Architecture).Assembly).Build(); - var sliceRule1 = SliceRuleDefinition - .Slices() - .MatchingWithPackages("ArchUnitNET.(*).(*).(*)"); + var sliceRule1 = SliceRuleDefinition.Slices().Matching("ArchUnitNET.(*).(*).(*)"); const string path = "../../../Fluent/PlantUml/Test.puml"; - var g = new GenerationOptions() { C4Style = true, LimitDependencies = true }; + var g = new GenerationOptions() + { + C4Style = true, + LimitDependencies = true, + NestSlicesInPackages = true, + }; ComponentDiagram() .WithDependenciesFromSlices(sliceRule1.GetObjects(arch1), g) .WriteToFile(path); diff --git a/ArchUnitNETTests/Fluent/Slices/MatchingWithPackagesTests.cs b/ArchUnitNETTests/Fluent/Slices/MatchingWithPackagesTests.cs new file mode 100644 index 000000000..2b38579ed --- /dev/null +++ b/ArchUnitNETTests/Fluent/Slices/MatchingWithPackagesTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Linq; +using ArchUnitNET.Domain; +using ArchUnitNET.Domain.PlantUml.Export; +using ArchUnitNET.Fluent; +using ArchUnitNET.Fluent.Slices; +using Xunit; + +// These tests cover the obsolete method itself. +#pragma warning disable CS0618 + +namespace ArchUnitNETTests.Fluent.Slices +{ + /// + /// is obsolete. It creates exactly the + /// slices creates, so its rules behave the same, + /// and the only thing it still does differently is nest its slices in diagrams by default. + /// + public class MatchingWithPackagesTests + { + private static Architecture Architecture => StaticTestArchitectures.SlicesTestArchitecture; + + private const string Root = "SlicesTestAssembly.MultipleSubnamespaces."; + + public static TheoryData Patterns => + new TheoryData + { + Root + "(*)", + Root + "(**)", + Root + "(**)..", + Root + "(*).(*)", + Root + "(*)..(*)", + "..DuplicatePrefix.Sub.(**)", + "(**).Service", + "SlicesTestAssembly.SubnamespaceCircle.(**)", + }; + + [Theory] + [MemberData(nameof(Patterns))] + public void Slices_AreThoseOfMatching(string pattern) + { + var withPackages = SliceRuleDefinition + .Slices() + .MatchingWithPackages(pattern) + .GetObjects(Architecture) + .ToList(); + var matching = SliceRuleDefinition.Slices().Matching(pattern).GetObjects(Architecture); + + Assert.Equal(matching.Select(Describe), withPackages.Select(Describe)); + } + + [Theory] + [MemberData(nameof(Patterns))] + public void Rules_EvaluateLikeThoseOfMatching(string pattern) + { + AssertSameResults( + SliceRuleDefinition.Slices().Matching(pattern).Should().BeFreeOfCycles(), + SliceRuleDefinition.Slices().MatchingWithPackages(pattern).Should().BeFreeOfCycles() + ); + AssertSameResults( + SliceRuleDefinition.Slices().Matching(pattern).Should().NotDependOnEachOther(), + SliceRuleDefinition + .Slices() + .MatchingWithPackages(pattern) + .Should() + .NotDependOnEachOther() + ); + } + + [Fact] + public void BeFreeOfCycles_DetectsCycle() + { + Assert.False( + SliceRuleDefinition + .Slices() + .MatchingWithPackages(Root + "(**)") + .Should() + .BeFreeOfCycles() + .HasNoViolations(Architecture) + ); + } + + // SubnamespaceCircle has real edges (Slice1 -> Slice2, Slice2.Inner -> Slice1) but no + // cycle, so an empty graph is not what makes this pass. + [Fact] + public void BeFreeOfCycles_WhenAcyclic_Passes() + { + Assert.True( + SliceRuleDefinition + .Slices() + .MatchingWithPackages("SlicesTestAssembly.SubnamespaceCircle.(**)") + .Should() + .BeFreeOfCycles() + .HasNoViolations(Architecture) + ); + } + + [Theory] + [MemberData(nameof(Patterns))] + public void Diagram_IsThatOfMatchingNestedInPackages(string pattern) + { + foreach ( + var options in new[] + { + new GenerationOptions(), + new GenerationOptions { C4Style = true }, + new GenerationOptions { LimitDependencies = true }, + } + ) + { + var expected = new PlantUmlFileBuilder() + .WithDependenciesFrom( + SortedSlices(SliceRuleDefinition.Slices().Matching(pattern)), + new GenerationOptions + { + NestSlicesInPackages = true, + C4Style = options.C4Style, + LimitDependencies = options.LimitDependencies, + } + ) + .AsString(); + var actual = new PlantUmlFileBuilder() + .WithDependenciesFrom( + SortedSlices(SliceRuleDefinition.Slices().MatchingWithPackages(pattern)), + options + ) + .AsString(); + + Assert.Equal(expected, actual); + } + } + + [Fact] + public void Diagram_FocusOnQualifiedPackage_IsThatOfMatchingNestedInPackages() + { + var expected = new PlantUmlFileBuilder() + .WithDependenciesFromFocusOn( + SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)")), + Root + "Slice1", + new GenerationOptions { NestSlicesInPackages = true } + ) + .AsString(); + var actual = new PlantUmlFileBuilder() + .WithDependenciesFromFocusOn( + SortedSlices(SliceRuleDefinition.Slices().MatchingWithPackages(Root + "(**)")), + Root + "Slice1" + ) + .AsString(); + + Assert.Equal(expected, actual); + Assert.Contains("package \"SlicesTestAssembly.MultipleSubnamespaces\"", actual); + } + + private static string Describe(Slice slice) + { + return slice.Description + + ": " + + string.Join( + ", ", + slice + .Types.Select(type => type.FullName) + .OrderBy(n => n, StringComparer.Ordinal) + ); + } + + private static void AssertSameResults(IArchRule matching, IArchRule withPackages) + { + Assert.Equal(matching.Description, withPackages.Description); + Assert.Equal( + matching.Evaluate(Architecture).Select(Summarize), + withPackages.Evaluate(Architecture).Select(Summarize) + ); + } + + private static string Summarize(EvaluationResult result) + { + return result.Passed + + " " + + result.EvaluatedObjectIdentifier.Identifier + + " " + + result.Description; + } + + private static Slice[] SortedSlices(GivenSlices slices) + { + return slices + .GetObjects(Architecture) + .OrderBy(slice => slice.Description, StringComparer.Ordinal) + .ToArray(); + } + } +} diff --git a/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs b/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs index bf2866ab8..0c2b92e02 100644 --- a/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/SliceDomainTests.cs @@ -116,6 +116,17 @@ public void SliceIdentifier_Equals_Branches() Assert.False(identifier.Equals(SliceIdentifier.Of("B"))); } + [Fact] + public void SliceIdentifier_Equals_IgnoresDiagramData() + { + // Where a diagram draws the slice is no part of its identity. + var nested = SliceIdentifier.OfMatch("A.B", null, "X.", "A.B"); + + Assert.Equal(SliceIdentifier.Of("A.B"), nested); + Assert.Equal(SliceIdentifier.Of("A.B").GetHashCode(), nested.GetHashCode()); + Assert.Equal("A.B", nested.Description); + } + [Fact] public void SliceIdentifierComparer_Equals_NullFirstArgument_ReturnsFalse() { diff --git a/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs b/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs index 143d03af5..232cb4a2b 100644 --- a/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs +++ b/ArchUnitNETTests/Fluent/Slices/SlicesTests.cs @@ -27,48 +27,6 @@ public void CycleDetectionTest() .AssertNoViolations(helper); } - // --- Rule evaluation driven by MatchingWithPackages (not just Matching) ------- - - [Fact] - public void BeFreeOfCycles_MatchingWithPackages_DetectsCycle() - { - var helper = new SlicesAssemblyTestHelper().WithoutSnapshot(); - SliceRuleDefinition - .Slices() - .MatchingWithPackages("SlicesTestAssembly.MultipleSubnamespaces.(**)") - .Should() - .BeFreeOfCycles() - .AssertOnlyViolations(helper); - } - - // SubnamespaceCircle has real edges (Slice1 -> Slice2, Slice2.Inner -> Slice1) but no - // cycle, unlike DotDotSemantics whose fixtures have no members and thus no edges at all -- - // an empty graph would pass here even if BeFreeOfCycles always returned "no cycles". - [Fact] - public void BeFreeOfCycles_MatchingWithPackages_WhenAcyclic_Passes() - { - var helper = new SlicesAssemblyTestHelper().WithoutSnapshot(); - SliceRuleDefinition - .Slices() - .MatchingWithPackages("SlicesTestAssembly.SubnamespaceCircle.(**)") - .Should() - .BeFreeOfCycles() - .AssertNoViolations(helper); - } - - [Fact] - public Task NotDependOnEachOther_MatchingWithPackages_ReportsViolations() - { - var helper = new SlicesAssemblyTestHelper(); - var rule = SliceRuleDefinition - .Slices() - .MatchingWithPackages("SlicesTestAssembly.MultipleSubnamespaces.(**)..") - .Should() - .NotDependOnEachOther(); - rule.AssertAnyViolations(helper); - return helper.AssertSnapshotMatches(); - } - [Fact] public void MatchingTest() { @@ -305,21 +263,20 @@ public void Matching_LeadingDotDot_CapturesEveryDepth() // (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" -- and the one leading dot "..DuplicatePrefix.Sub." keeps after - // stripping only one of its two literal dots -- 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. + // "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. [Fact] - public void MatchingWithPackages_DuplicatePrefixSegment_UsesFirstOccurrenceAsPrefix() + public void DuplicatePrefixSegment_CapturesAfterTheFirstOccurrenceOfThePrefix() { var slices = SliceRuleDefinition .Slices() - .MatchingWithPackages("..DuplicatePrefix.Sub.(*)") + .Matching("..DuplicatePrefix.Sub.(**)") .GetObjects(StaticTestArchitectures.SlicesTestArchitecture) .ToList(); Assert.Single(slices); - Assert.Equal(".DuplicatePrefix.Sub.DuplicatePrefix.Sub", slices[0].Description); + Assert.Equal("DuplicatePrefix.Sub", slices[0].Description); } // --- ".." semantics ------------------------------------------------------------- diff --git a/ArchUnitNETTests/Fluent/Slices/Snapshots/SlicesTests.NotDependOnEachOther_MatchingWithPackages_ReportsViolations.verified.txt b/ArchUnitNETTests/Fluent/Slices/Snapshots/SlicesTests.NotDependOnEachOther_MatchingWithPackages_ReportsViolations.verified.txt deleted file mode 100644 index ae856ef18..000000000 --- a/ArchUnitNETTests/Fluent/Slices/Snapshots/SlicesTests.NotDependOnEachOther_MatchingWithPackages_ReportsViolations.verified.txt +++ /dev/null @@ -1,19 +0,0 @@ -Query: Slices matching "SlicesTestAssembly.MultipleSubnamespaces.(**).." should not depend on each other -Result: False -Description: SlicesTestAssembly.MultipleSubnamespaces.Slice1 does depend on other slices: -SlicesTestAssembly.MultipleSubnamespaces.Slice1 -> SlicesTestAssembly.MultipleSubnamespaces.Slice2 - SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service.Service1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service.Service2Class - -Result: True -Description: SlicesTestAssembly.MultipleSubnamespaces.Slice2 does not depend on another slice. -Result: True -Description: SlicesTestAssembly.MultipleSubnamespaces.Slice3 does not depend on another slice. -Message: -"Slices matching "SlicesTestAssembly.MultipleSubnamespaces.(**).." should not depend on each other" failed: - SlicesTestAssembly.MultipleSubnamespaces.Slice1 does depend on other slices: -SlicesTestAssembly.MultipleSubnamespaces.Slice1 -> SlicesTestAssembly.MultipleSubnamespaces.Slice2 - SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service.Service1Class -> SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service.Service2Class - - - - diff --git a/documentation/docs/guide.md b/documentation/docs/guide.md index 022bf60d9..2c522a8c1 100644 --- a/documentation/docs/guide.md +++ b/documentation/docs/guide.md @@ -239,17 +239,18 @@ PlantUmlDefinition.ComponentDiagram().WithDependenciesFromSlices(sliceRule.GetOb ``` ### 5.2 Structured diagram dependencies -On the previous case there are too many dependencies, this option is suitable mainly for small projects / architectures / slices. With an increase in the number of objects, it makes sense to introduce another type of display - a display with packages. It differs from the previous case only with the modified creation of SliceRule. +On the previous case there are too many dependencies, this option is suitable mainly for small projects / architectures / slices. With an increase in the number of objects, it makes sense to introduce another type of display - a display with packages. It differs from the previous case only in the `NestSlicesInPackages` option, which nests the slices of a pattern in `package` blocks along the namespace they matched. A slice that does not come from a pattern, or whose types matched different namespaces before the capture group, is drawn on its own under its name. ![Diagram](diagrams/archUnitNet_all_withPackages.svg) ```cs string pattern = "ArchUnitNET.(**)"; -GivenSlices sliceRule = SliceRuleDefinition.Slices().MatchingWithPackages(pattern); +GivenSlices sliceRule = SliceRuleDefinition.Slices().Matching(pattern); Architecture arch = new ArchLoader().LoadAssembly(typeof(ArchUnitNET.Domain.Architecture).Assembly).Build(); +GenerationOptions g = new GenerationOptions(){NestSlicesInPackages = true}; string path = "diagram.puml"; -PlantUmlDefinition.ComponentDiagram().WithDependenciesFromSlices(sliceRule.GetObjects(arch)).WriteToFile(path); +PlantUmlDefinition.ComponentDiagram().WithDependenciesFromSlices(sliceRule.GetObjects(arch), g).WriteToFile(path); ``` ### 5.3 LimitDependencies-mod @@ -258,9 +259,9 @@ The previous case still shows a large number of connections. For maximum simplif ![Diagram](diagrams/archUnitNet_all_compact.svg) ```cs string pattern = "ArchUnitNET.(**)"; -GivenSlices sliceRule = SliceRuleDefinition.Slices().MatchingWithPackages(pattern); +GivenSlices sliceRule = SliceRuleDefinition.Slices().Matching(pattern); Architecture arch = new ArchLoader().LoadAssembly(typeof(ArchUnitNET.Domain.Architecture).Assembly).Build(); -GenerationOptions g = new GenerationOptions(){LimitDependencies = true}; +GenerationOptions g = new GenerationOptions(){NestSlicesInPackages = true, LimitDependencies = true}; string path = "diagram.puml"; @@ -298,9 +299,9 @@ To enable an C4plantUML-style view of diagrams, set in the GenerationOptions fla ![Diagram](diagrams/archUnitNet_three_alternative.svg) ```cs string pattern = "ArchUnitNET.(*).(*).(*)"; -GivenSlices sliceRule = SliceRuleDefinition.Slices().MatchingWithPackages(pattern); +GivenSlices sliceRule = SliceRuleDefinition.Slices().Matching(pattern); Architecture arch = new ArchLoader().LoadAssembly(typeof(ArchUnitNET.Domain.Architecture).Assembly).Build(); -GenerationOptions g = new GenerationOptions(){C4Style = true}; +GenerationOptions g = new GenerationOptions(){NestSlicesInPackages = true, C4Style = true}; string path = "diagram.puml"; @@ -308,20 +309,31 @@ PlantUmlDefinition.ComponentDiagram().WithDependenciesFromSlices(sliceRule.GetOb ``` ### 5.6 Focus-mod -Focus mod allows you to show all dependencies on the selected package or out of the package. +Focus mod allows you to show all dependencies on the selected package or out of the package. With `NestSlicesInPackages`, slices are known by their fully qualified path, so the package to focus on is fully qualified as well. ![Diagram](diagrams/archUnitNet_focusOn.svg) ```cs string pattern = "ArchUnitNET.(**)"; -string focusOnThisPackage = "ArchUnitNET.Fluent.Syntax.Elements" -GivenSlices sliceRule = SliceRuleDefinition.Slices().MatchingWithPackages(pattern); +string focusOnThisPackage = "ArchUnitNET.Fluent.Syntax.Elements"; +GivenSlices sliceRule = SliceRuleDefinition.Slices().Matching(pattern); Architecture arch = new ArchLoader().LoadAssembly(typeof(ArchUnitNET.Domain.Architecture).Assembly).Build(); +GenerationOptions g = new GenerationOptions(){NestSlicesInPackages = true}; string path = "diagram.puml"; -PlantUmlDefinition.ComponentDiagram().WithDependenciesFromSlices(sliceRule.GetObjects(arch), focusOnThisPackage).WriteToFile(path); +PlantUmlDefinition.ComponentDiagram().WithDependenciesFromSlices(sliceRule.GetObjects(arch), focusOnThisPackage, g).WriteToFile(path); ``` +### 5.7 Migrating from MatchingWithPackages +`MatchingWithPackages` is obsolete and will be removed in a future release. It creates the same slices +as `Matching`, with the same names and violations, and only nests them in diagrams. Replace +`MatchingWithPackages(pattern)` with `Matching(pattern)` and draw the slices with +`new GenerationOptions { NestSlicesInPackages = true }`. + +Failure messages of former `MatchingWithPackages` rules no longer start a slice's name with the +namespace before the first capture group, and their frozen violations, which were stored under those +longer names, have to be frozen again once. + ## 6. Further Reading and Examples A complete overview of all available methods can be found [here](additional.md).