From 873e29a2205c8fea2cf4f73a64efae00a1949971 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 16 Jul 2026 11:21:17 -0400 Subject: [PATCH 1/4] Fix IsRootRelevantToScope to respect NamespaceExpose self-exclusion semantics Per formal-26-03-02.md section 8.3.26.4, a NamespaceExpose (NamespaceRecursive / NamespaceDirectChildren) exposes a namespace's Memberships, never the namespace itself. IsRootRelevantToScope previously treated candidateQualifiedName == subject as always root-relevant regardless of the subject's ExposeRecursionKind, causing excluded subjects to still be drawn as the outer wrapping box in asInterconnectionDiagram renders. Now the self-match direction is only honored for MembershipRecursive/MembershipExact recursion kinds, which do include the subject itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Layout/Internal/ExposeScopeResolver.cs | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs index 1f69def5..c6003f05 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs @@ -245,9 +245,10 @@ private static bool IsDirectChildOf(string qualifiedName, string container) /// , , or /// would otherwise pick by its own heuristic) is related /// to the resolved expose scope in , in either containment - /// direction: the candidate itself is an exposed subject/matched member, the candidate lies - /// within an exposed subject's containment subtree, or an exposed subject/matched member lies - /// within the candidate's own containment subtree (the common "expose an inner + /// direction: the candidate itself is an exposed subject/matched member (subject to the same + /// per-recursion-kind self-exclusion applies — see below), the + /// candidate lies within an exposed subject's containment subtree, or an exposed subject/matched + /// member lies within the candidate's own containment subtree (the common "expose an inner /// state/action/part/lifeline of the root" case). /// /// @@ -258,6 +259,17 @@ private static bool IsDirectChildOf(string qualifiedName, string container) /// must break the tie themselves; the single-root FindRoot strategies do so via /// , which prefers the most deeply nested relevant candidate /// over the plain per-strategy score. + /// + /// The "candidate equals the subject" direction only applies when the subject's + /// kind actually includes the subject itself + /// ( or + /// ). Per formal-26-03-02.md §8.3.26.4, a + /// NamespaceExpose ( and + /// ) exposes the subject's + /// Memberships — its members — not the subject itself, so a candidate that is only reachable + /// via "candidate == subject" for a namespace-form subject must not be treated as root-relevant; + /// otherwise the excluded subject would still be drawn as the diagram's outer box. + /// /// /// The candidate root definition's qualified name. /// The resolved expose scope. @@ -267,12 +279,33 @@ private static bool IsDirectChildOf(string qualifiedName, string container) /// public static bool IsRootRelevantToScope(string candidateQualifiedName, ExposedScope scope) { - bool RelevantTo(string subject) => - candidateQualifiedName == subject || - candidateQualifiedName.StartsWith(subject + "::", StringComparison.Ordinal) || - subject.StartsWith(candidateQualifiedName + "::", StringComparison.Ordinal); + bool RelevantToSubject(ExposeSubject subject) + { + var subjectName = subject.QualifiedName; + + // Containment in either direction is always relevant, regardless of recursion kind: + // the candidate descending from the subject, or an exposed subject/feature nested + // inside the candidate. + if (candidateQualifiedName.StartsWith(subjectName + "::", StringComparison.Ordinal) || + subjectName.StartsWith(candidateQualifiedName + "::", StringComparison.Ordinal)) + { + return true; + } + + // "candidate == subject" is only relevant when this subject's recursion kind actually + // includes the subject itself in scope -- NamespaceRecursive/NamespaceDirectChildren + // exclude the subject (see remarks), so an excluded subject must not be root-relevant + // purely by virtue of matching itself. + return candidateQualifiedName == subjectName && + subject.Recursion is ExposeRecursionKind.MembershipRecursive or ExposeRecursionKind.MembershipExact; + } + + bool RelevantToMember(string member) => + candidateQualifiedName == member || + candidateQualifiedName.StartsWith(member + "::", StringComparison.Ordinal) || + member.StartsWith(candidateQualifiedName + "::", StringComparison.Ordinal); - return scope.Subjects.Select(s => s.QualifiedName).Any(RelevantTo) || scope.ExplicitMembers.Any(RelevantTo); + return scope.Subjects.Any(RelevantToSubject) || scope.ExplicitMembers.Any(RelevantToMember); } /// From 5d64525e30419d561ad37d229cc252cbc0ba57f6 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 16 Jul 2026 11:57:08 -0400 Subject: [PATCH 2/4] Render top-level exposed features without a frame when no root def matches InterconnectionViewLayoutStrategy.FindRoot previously fell back to a totally empty canvas whenever no single part def could be selected as the diagram root, even when the resolved expose scope directly named one or more concrete top-level part feature usages. This produced an empty diagram for scopes like `expose Subsystem::*;` where Subsystem is a namespace-like part def whose only nested content is a single part usage. BuildLayout now falls back to collecting those top-level scoped part features (CollectTopLevelScopedParts) and laying each out with the same recursive container-vs-leaf logic already used by CollectParts, factored out into BuildPartItem. Connections between the collected top-level features are resolved via ResolveTopLevelConnections and drawn as before. The features render side by side with no enclosing outer frame/title box. LayOutInteriorWithConnections is parameterized with boxDepth and reserveTitleArea so it can be reused for both the normal single-root interior and this new flat top-level case. When the scope matches no top-level feature either, the original empty-canvas fallback (`new LayoutTree(200.0, 100.0, [])`) is preserved exactly. Deviation from plan: BuildPartItem omits the ExposedScope? scope parameter specified in the plan, since the original CollectParts always passed scope: null to its recursive call regardless of its own scope argument, making the parameter provably dead in the extracted method. Adds 6 regression tests covering: the direct repro (single top-level feature, no root def), two sibling top-level features arranged side by side, a top-level feature that is itself a container (recurses into its own interior), no matching feature (empty-canvas fallback preserved), a connection drawn between two top-level features, and a nested feature correctly excluded from top-level collection (not duplicated). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InterconnectionViewLayoutStrategy.cs | 215 +++++++++- .../InterconnectionViewLayoutStrategyTests.cs | 368 ++++++++++++++++++ 2 files changed, 565 insertions(+), 18 deletions(-) diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs index 2a4449eb..11174d79 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs @@ -41,6 +41,16 @@ namespace DemaConsulting.SysML2Tools.Layout.Internal; /// non-recursive layout. Recursion is driven here, at the strategy level, because container detection /// is a semantic-model concern the model-independent algorithm cannot see. /// +/// +/// Not every resolved expose scope names a single part def worth treating as "the" +/// subject: per SysML v2 §8.3.26.11/§9.2.20.2.6, an InterconnectionView's exposed content can be one +/// or more concrete feature usages directly, with no enclosing definition of its own. When +/// selects no root but the scope directly includes one or more top-level +/// part feature usages, those features are rendered as boxless nodes side by side (via +/// ) instead of the diagram falling back to an empty canvas +/// — each one recursing into its own interior exactly as a normal nested container part would, +/// reusing so the container-vs-leaf logic is never duplicated. +/// /// internal sealed class InterconnectionViewLayoutStrategy : ILayoutStrategy { @@ -103,6 +113,27 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var root = FindRoot(context.Workspace, scope, defsByName); if (root is null) { + // No single part def qualifies as "the" root. Per SysML v2 §8.3.26.11/§9.2.20.2.6, an + // InterconnectionView's subject need not be one definition — when the resolved expose + // scope directly names one or more concrete part feature usages, render those as + // boxless nodes side by side (via the same sensible existing layout helper, + // LayeredPlacement.PlaceWithPorts, used for every other case) instead of falling back to + // a totally empty canvas. When there is nothing concrete to draw either (no scope at + // all, or a scope that matches no part feature), the existing empty-canvas fallback is + // preserved unchanged. + if (scope is not null) + { + var topLevelParts = CollectTopLevelScopedParts(context.Workspace, scope, theme, defsByName); + if (topLevelParts.Count > 0) + { + var partIndex = BuildPartIndex(topLevelParts); + var pairs = ResolveTopLevelConnections(defsByName, partIndex, scope); + var layout = LayOutInteriorWithConnections( + topLevelParts, pairs, theme, boxDepth: 0, reserveTitleArea: false); + return new LayoutTree(layout.Width, layout.Height, layout.Content); + } + } + return new LayoutTree(200.0, 100.0, []); } @@ -170,18 +201,35 @@ private static InteriorLayout LayOutInterior( var partIndex = BuildPartIndex(parts); var pairs = ResolveConnections(def, partIndex); - return LayOutInteriorWithConnections(parts, pairs, theme, depth); + return LayOutInteriorWithConnections(parts, pairs, theme, boxDepth: depth + 1); } /// /// Lays out a definition's parts when at least one connection exists between them, delegating /// placement and orthogonal edge routing to the bundled layered algorithm. /// + /// The parts to place. + /// The resolved connections between the parts. + /// The active rendering theme. + /// + /// The to stamp on each placed part's own box (not the + /// container's — the caller passes its own container depth, plus one, for a normal nested + /// interior; the no-single-root fallback in passes 0 directly + /// since there is no enclosing container box at all in that path). + /// + /// + /// Whether to reserve a title band above the placed content, as a normal container box's own + /// title requires. (the default) for every existing container-interior + /// caller; only for the no-single-root boxless fallback in + /// , where there is no enclosing frame/title to make room for — the + /// returned size is then just the bounding box of the placed content plus normal padding. + /// private static InteriorLayout LayOutInteriorWithConnections( IReadOnlyList parts, IReadOnlyList pairs, Theme theme, - int depth) + int boxDepth, + bool reserveTitleArea = true) { var nodeSizes = parts.Select(p => (p.Width, p.Height, HasLabel: true, HasKeyword: true)).ToList(); @@ -205,8 +253,10 @@ private static InteriorLayout LayOutInteriorWithConnections( // independently-routed connector instead of collapsing onto one shared route. var placed = LayeredPlacement.PlaceWithPorts(nodeSizes, portEdges, LayoutFlowDirection.Right); - // Shift placed content down/right to sit inside the container box. - var titleArea = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true); + // Shift placed content down/right to sit inside the container box. When there is no + // enclosing container (the boxless top-level fallback), no title band is reserved, so the + // top offset collapses to the same padding-only inset used on every other side. + var titleArea = reserveTitleArea ? BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true) : 0.0; var offsetX = theme.LabelPadding * 2.0; var offsetY = titleArea + (theme.LabelPadding * 2.0); @@ -233,7 +283,7 @@ private static InteriorLayout LayOutInteriorWithConnections( for (var i = 0; i < parts.Count; i++) { var r = placed.Rects[i]; - content.Add(MakePartBox(parts[i], new Rect(r.X + offsetX, r.Y + offsetY, r.Width, r.Height), depth + 1)); + content.Add(MakePartBox(parts[i], new Rect(r.X + offsetX, r.Y + offsetY, r.Width, r.Height), boxDepth)); } // One port pair and one connector line per connection. The algorithm returns exactly one @@ -430,29 +480,158 @@ private static IReadOnlyList CollectParts( continue; } - var name = feature.Name ?? feature.FeatureTyping ?? "part"; + result.Add(BuildPartItem(feature, theme, depth, defsByName, visited)); + } + + return result; + } + + /// + /// Builds a single nested part usage's , recursing into its interior when + /// the part's type resolves to a container definition (a non-stdlib part def with its own + /// internal parts, not already on the recursion path) and computing an intrinsic leaf size + /// otherwise. Extracted from so the same container-vs-leaf recursion + /// is reused verbatim by — the boxless top-level + /// fallback path must lay out each top-level feature exactly as a normal nested part would be, + /// per the "no duplicated logic" coding principle. + /// + /// The part feature usage to build a for. + /// The active rendering theme. + /// Nesting depth of the feature's own container box (0 for a top-level part). + /// Container-definition index keyed by qualified and simple name. + /// Qualified names already on the recursion path, guarding against cycles. + /// The built , container or leaf. + private static PartItem BuildPartItem( + SysmlFeatureNode feature, + Theme theme, + int depth, + IReadOnlyDictionary defsByName, + ISet visited) + { + var name = feature.Name ?? feature.FeatureTyping ?? "part"; + + if (TryResolveContainer(feature.FeatureTyping, defsByName, visited, out var childDef)) + { + // Container part: lay out its interior bottom-up and treat it as an atomic node. + // Scope is intentionally not carried into this recursive call — see the remarks on + // CollectParts for why nested composition structure is always shown once its owning + // part has been included, regardless of the exposed namespace scope. + var childVisited = new HashSet(visited, StringComparer.Ordinal) { childDef.QualifiedName! }; + var inner = LayOutInterior(childDef, theme, depth + 1, defsByName, childVisited, scope: null); + return new PartItem(name, "part", feature.FeatureTyping, inner.Width, inner.Height, inner.Content); + } + + // Leaf part: intrinsic size, no nested content. + var (width, height) = ComputePartSize(name, feature.FeatureTyping, theme); + return new PartItem(name, "part", feature.FeatureTyping, width, height, null); + } + + /// + /// Collects every top-level part feature usage the resolved expose scope directly + /// includes, for the no-single-root fallback path: found no part def + /// worth rendering as a container, but the scope itself names one or more concrete features to + /// draw. Per SysML v2 spec §9.2.20.2.6 ("exposed features as nodes, nested features as nested + /// nodes") and §8.3.26.11 (an InterconnectionView's subject need not be a single definition), + /// each matching feature is rendered directly as its own top-level node rather than the diagram + /// falling back to an empty canvas. + /// + /// The workspace, scanned for every non-stdlib part feature usage. + /// The view's resolved expose containment-subtree scope. + /// The active rendering theme. + /// Container-definition index keyed by qualified and simple name. + /// + /// The matched top-level parts, each recursively laid out via exactly + /// as a normal nested container part would be, in 's declaration + /// order. Empty when no non-stdlib part feature satisfies the scope. + /// + private static IReadOnlyList CollectTopLevelScopedParts( + SysmlWorkspace workspace, + ExposedScope scope, + Theme theme, + IReadOnlyDictionary defsByName) + { + var matched = new List(); + foreach (var (qualifiedName, node) in workspace.Declarations) + { + if (node is not SysmlFeatureNode feature || feature.FeatureKeyword != "part") + { + continue; + } - if (TryResolveContainer(feature.FeatureTyping, defsByName, visited, out var childDef)) + if (StdlibFilter.IsStdlibElement(qualifiedName, workspace.StdlibNames)) { - // Container part: lay out its interior bottom-up and treat it as an atomic node. - // Scope is intentionally not carried into this recursive call — see the remarks - // above on why nested composition structure is always shown once its owning part - // has been included, regardless of the exposed namespace scope. - var childVisited = new HashSet(visited, StringComparer.Ordinal) { childDef.QualifiedName! }; - var inner = LayOutInterior(childDef, theme, depth + 1, defsByName, childVisited, scope: null); - result.Add(new PartItem(name, "part", feature.FeatureTyping, inner.Width, inner.Height, inner.Content)); + continue; } - else + + if (!ExposeScopeResolver.IsInSubjectScope(qualifiedName, scope)) { - // Leaf part: intrinsic size, no nested content. - var (width, height) = ComputePartSize(name, feature.FeatureTyping, theme); - result.Add(new PartItem(name, "part", feature.FeatureTyping, width, height, null)); + continue; } + + matched.Add(feature); + } + + // Exclude any matched feature nested ("::"-prefixed) under another matched feature's own + // qualified name: it is already reachable as that ancestor's own nested part (rendered via + // the recursive BuildPartItem call below), so it must not also appear as its own separate + // top-level node. + var matchedNames = matched.Select(f => f.QualifiedName).Where(n => n is { Length: > 0 }).ToHashSet(StringComparer.Ordinal); + var topLevel = matched + .Where(f => f.QualifiedName is not { Length: > 0 } fqn || + !matchedNames.Any(other => other != fqn && fqn.StartsWith(other + "::", StringComparison.Ordinal))) + .ToList(); + + var result = new List(); + foreach (var feature in topLevel) + { + var visited = new HashSet(StringComparer.Ordinal); + result.Add(BuildPartItem(feature, theme, depth: 0, defsByName, visited)); } return result; } + /// + /// Resolves connections between the boxless top-level parts collected by + /// , reusing verbatim. + /// Unlike (which only looks at one definition's own direct + /// children), a top-level connection may be declared inside any definition in the workspace, so + /// every definition's connections are scanned; a connection is only drawn when its own + /// QualifiedName is itself in scope and both endpoints resolve into the top-level + /// part set — an incidental connection between unrelated parts must never be surfaced just + /// because it happens to share a name with one of the rendered top-level features. + /// + /// Container-definition index keyed by qualified and simple name. + /// Name → index lookup for the collected top-level parts. + /// The view's resolved expose containment-subtree scope. + /// The resolved connection pairs between top-level parts, if any. + private static IReadOnlyList ResolveTopLevelConnections( + IReadOnlyDictionary defsByName, + Dictionary partIndex, + ExposedScope scope) + { + var pairs = new List(); + foreach (var def in defsByName.Values.Distinct()) + { + foreach (var conn in def.Children.OfType()) + { + if (conn.QualifiedName is not { Length: > 0 } fqn || !ExposeScopeResolver.IsInSubjectScope(fqn, scope)) + { + continue; + } + + var (a, labelA) = ResolveEndpoint(conn.EndpointA, partIndex); + var (b, labelB) = ResolveEndpoint(conn.EndpointB, partIndex); + if (a >= 0 && b >= 0 && a != b) + { + pairs.Add(new ConnPair(a, b, labelA, labelB)); + } + } + } + + return pairs; + } + /// /// Builds an index of candidate container definitions — non-standard-library part defs /// that have at least one nested part usage — keyed by both qualified and simple name diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs index 0e600531..ef6f1d33 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs @@ -1402,4 +1402,372 @@ public void InterconnectionView_BuildLayout_UnconnectedPeerParts_PacksIntoCompac Assert.True(distinctX > 1, "Expected parts to span more than one column, not a single vertical column."); Assert.True(distinctY < parts.Count, "Expected some parts to share a row, not one row per part."); } + + /// + /// Regression test for the reported crash/empty-diagram bug: a view exposing a namespace's + /// direct children (expose PublishingSubsystem::*;) where PublishingSubsystem + /// is itself only a namespace-like part def with a single nested part feature + /// usage (markdownFormatter) typed by a leaf part def. FindRoot selects + /// no root (PublishingSubsystem is excluded from self-matching by the + /// NamespaceDirectChildren recursion kind), so before this fix the diagram rendered + /// as a totally empty canvas. Now the exposed direct-child feature renders directly as a + /// single boxless leaf node, with no PublishingSubsystem-labeled frame anywhere. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NoRootDef_RendersTopLevelFeatureWithoutFrame() + { + // Arrange: PublishingSubsystem::markdownFormatter : MarkdownFormatter (a leaf part def), + // exposed via "expose PublishingSubsystem::*;" (NamespaceDirectChildren, no trailing "::**"). + var strategy = new InterconnectionViewLayoutStrategy(); + var publishingSubsystem = new SysmlDefinitionNode + { + Name = "PublishingSubsystem", + QualifiedName = "M::PublishingSubsystem", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode + { + Name = "markdownFormatter", + QualifiedName = "M::PublishingSubsystem::markdownFormatter", + FeatureKeyword = "part", + FeatureTyping = "MarkdownFormatter" + } + ] + }; + var markdownFormatter = new SysmlDefinitionNode + { + Name = "MarkdownFormatter", + QualifiedName = "M::MarkdownFormatter", + DefinitionKeyword = "part def" + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["M::PublishingSubsystem"] = publishingSubsystem, + ["M::PublishingSubsystem::markdownFormatter"] = publishingSubsystem.Children[0], + ["M::MarkdownFormatter"] = markdownFormatter + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposeMembers = [new ExposeMember("PublishingSubsystem", null, ExposeRecursionKind.NamespaceDirectChildren)], + ResolvedEdges = [new SysmlEdge("M::V", "M::PublishingSubsystem", SysmlEdgeKind.Expose)] + }.WithResolvedExposeMembers(); + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: exactly one box overall (no PublishingSubsystem frame anywhere), a leaf/interior + // rounded box labeled "markdownFormatter : MarkdownFormatter" with keyword "part". + var boxes = CollectBoxes(layout.Nodes).ToList(); + var box = Assert.Single(boxes); + Assert.Equal("part", box.Keyword); + Assert.Contains("markdownFormatter", box.Label, StringComparison.Ordinal); + Assert.Equal(BoxShape.RoundedRectangle, box.Shape); + Assert.DoesNotContain(boxes, b => b.Label is not null && b.Label.Contains("PublishingSubsystem", StringComparison.Ordinal)); + } + + /// + /// Two independent top-level part feature usages that are both direct children of a + /// common exposed namespace render as two separate boxless nodes placed side by side (via + /// the shared LayeredPlacement.PlaceWithPorts containment-packing algorithm), with no + /// wrapping frame and no overlap between them. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_TwoTopLevelParts_ArrangesSideBySideNoFrame() + { + // Arrange: NsRoot::widgetA and NsRoot::widgetB, each typed by an unrelated leaf part def, + // exposed via "expose NsRoot::*;" (NamespaceDirectChildren). + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = BuildTwoTopLevelPartsWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposeMembers = [new ExposeMember("NsRoot", null, ExposeRecursionKind.NamespaceDirectChildren)], + ResolvedEdges = [new SysmlEdge("M::V", "NsRoot", SysmlEdgeKind.Expose)] + }.WithResolvedExposeMembers(); + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: exactly two top-level boxes (no wrapping frame), and they do not overlap. + Assert.Equal(2, layout.Nodes.Count); + var boxes = layout.Nodes.OfType().ToList(); + Assert.Equal(2, boxes.Count); + Assert.False(Overlaps(boxes[0], boxes[1]), "the two top-level boxes overlap"); + } + + /// + /// When the exposed namespace's direct-child part is itself typed by a part def that + /// has its own nested parts, the single top-level box's own Children contain the + /// nested part boxes — proving the boxless top-level fallback reuses the same recursive + /// container detection (BuildPartItem) as every other nested part, rather than + /// duplicating a shallow copy of that logic. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_TopLevelFeatureIsContainer_RecursesInterior() + { + // Arrange: NsRoot::board : Motherboard { cpu, chipset, connect cpu to chipset } — two levels + // deep — exposed via "expose NsRoot::*;" (NamespaceDirectChildren). + var strategy = new InterconnectionViewLayoutStrategy(); + var motherboard = new SysmlDefinitionNode + { + Name = "Motherboard", + QualifiedName = "M::Motherboard", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "cpu", QualifiedName = "M::Motherboard::cpu", FeatureKeyword = "part", FeatureTyping = "Cpu" }, + new SysmlFeatureNode { Name = "chipset", QualifiedName = "M::Motherboard::chipset", FeatureKeyword = "part", FeatureTyping = "Chipset" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "cpu", EndpointB = "chipset" } + ] + }; + var board = new SysmlFeatureNode + { + Name = "board", + QualifiedName = "NsRoot::board", + FeatureKeyword = "part", + FeatureTyping = "Motherboard" + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["NsRoot::board"] = board, + ["M::Motherboard"] = motherboard + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposeMembers = [new ExposeMember("NsRoot", null, ExposeRecursionKind.NamespaceDirectChildren)], + ResolvedEdges = [new SysmlEdge("M::V", "NsRoot", SysmlEdgeKind.Expose)] + }.WithResolvedExposeMembers(); + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: exactly one top-level box (no frame), and its own Children hold the nested + // cpu/chipset part boxes recursed via the shared BuildPartItem logic. + var topBox = Assert.Single(layout.Nodes.OfType()); + Assert.Contains("board", topBox.Label, StringComparison.Ordinal); + var nestedLabels = CollectBoxes(topBox.Children).Select(b => b.Label).ToList(); + Assert.Contains(nestedLabels, l => l is not null && l.Contains("cpu", StringComparison.Ordinal)); + Assert.Contains(nestedLabels, l => l is not null && l.Contains("chipset", StringComparison.Ordinal)); + } + + /// + /// Pins down the preserved existing empty-canvas fallback (requirement: no regression): when + /// the resolved expose scope matches no part feature at all (its only member + /// is a non-part feature), FindRoot still returns no root, and + /// CollectTopLevelScopedParts returns an empty list, so the diagram falls back to the + /// unchanged minimal canvas rather than the new boxless-nodes path. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NoMatchingFeature_ReturnsMinimalCanvas() + { + // Arrange: NsRoot::onlyAttribute is an "attribute" feature, not a "part" — no part + // feature is a direct child of the exposed namespace. + var strategy = new InterconnectionViewLayoutStrategy(); + var onlyAttribute = new SysmlFeatureNode + { + Name = "onlyAttribute", + QualifiedName = "NsRoot::onlyAttribute", + FeatureKeyword = "attribute", + FeatureTyping = "String" + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["NsRoot::onlyAttribute"] = onlyAttribute } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposeMembers = [new ExposeMember("NsRoot", null, ExposeRecursionKind.NamespaceDirectChildren)], + ResolvedEdges = [new SysmlEdge("M::V", "NsRoot", SysmlEdgeKind.Expose)] + }.WithResolvedExposeMembers(); + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: unchanged minimal-canvas fallback. + Assert.Empty(layout.Nodes); + } + + /// + /// A connector declared between two top-level scoped part features is still drawn when the + /// connector's own qualified name is itself in scope and both endpoints resolve into the + /// top-level part set (requirement: "if a connector is itself resolvable and in scope, draw + /// it"). + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_ConnectionBetweenTopLevelFeatures_DrawsEdge() + { + // Arrange: NsRoot { widgetA, widgetB, connect widgetA to widgetB } — the connection's own + // qualified name is a direct child of NsRoot, so it too satisfies the NamespaceDirectChildren + // scope alongside its two endpoints. + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = BuildTwoTopLevelPartsWithConnectionWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposeMembers = [new ExposeMember("NsRoot", null, ExposeRecursionKind.NamespaceDirectChildren)], + ResolvedEdges = [new SysmlEdge("M::V", "NsRoot", SysmlEdgeKind.Expose)] + }.WithResolvedExposeMembers(); + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: two top-level boxes, connected by exactly one connector line. + Assert.Equal(2, layout.Nodes.OfType().Count()); + Assert.Single(CollectLines(layout.Nodes)); + } + + /// + /// A matched feature whose own qualified name is itself nested ("::"-prefixed) under + /// another matched feature's qualified name is excluded from the top-level set — it is + /// already reachable as that ancestor's own nested content, so it must not also be + /// duplicated as its own separate top-level node. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NestedTopLevelFeatureExcluded_NotDuplicated() + { + // Arrange: an exotic scope shape where both "NsRoot::widgetA" (the exposed subject itself, + // MembershipRecursive so self-matches) and "NsRoot::widgetA::sub" (nested under it by + // qualified-name prefix alone) satisfy IsInSubjectScope for the same subject. + var strategy = new InterconnectionViewLayoutStrategy(); + var widgetA = new SysmlFeatureNode + { + Name = "widgetA", + QualifiedName = "NsRoot::widgetA", + FeatureKeyword = "part", + FeatureTyping = "WidgetA" + }; + var sub = new SysmlFeatureNode + { + Name = "sub", + QualifiedName = "NsRoot::widgetA::sub", + FeatureKeyword = "part", + FeatureTyping = "SubPart" + }; + var widgetADef = new SysmlDefinitionNode { Name = "WidgetA", QualifiedName = "M::WidgetA", DefinitionKeyword = "part def" }; + var subPartDef = new SysmlDefinitionNode { Name = "SubPart", QualifiedName = "M::SubPart", DefinitionKeyword = "part def" }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["NsRoot::widgetA"] = widgetA, + ["NsRoot::widgetA::sub"] = sub, + ["M::WidgetA"] = widgetADef, + ["M::SubPart"] = subPartDef + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposeMembers = [new ExposeMember("widgetA", null, ExposeRecursionKind.MembershipRecursive)], + ResolvedEdges = [new SysmlEdge("M::V", "NsRoot::widgetA", SysmlEdgeKind.Expose)] + }.WithResolvedExposeMembers(); + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: only the outer (ancestor) feature appears as a top-level box; "sub" is not + // duplicated as its own separate top-level node. + var topBoxes = layout.Nodes.OfType().ToList(); + var topBox = Assert.Single(topBoxes); + Assert.Contains("widgetA", topBox.Label, StringComparison.Ordinal); + Assert.DoesNotContain("sub", topBox.Label, StringComparison.Ordinal); + } + + /// + /// Builds a workspace with two independent top-level parts (NsRoot::widgetA and + /// NsRoot::widgetB), each typed by its own unrelated leaf part def, and no + /// connection between them. + /// + private static SysmlWorkspace BuildTwoTopLevelPartsWorkspace() + { + var widgetA = new SysmlFeatureNode { Name = "widgetA", QualifiedName = "NsRoot::widgetA", FeatureKeyword = "part", FeatureTyping = "WidgetA" }; + var widgetB = new SysmlFeatureNode { Name = "widgetB", QualifiedName = "NsRoot::widgetB", FeatureKeyword = "part", FeatureTyping = "WidgetB" }; + var widgetADef = new SysmlDefinitionNode { Name = "WidgetA", QualifiedName = "M::WidgetA", DefinitionKeyword = "part def" }; + var widgetBDef = new SysmlDefinitionNode { Name = "WidgetB", QualifiedName = "M::WidgetB", DefinitionKeyword = "part def" }; + return new SysmlWorkspace + { + Declarations = new Dictionary + { + ["NsRoot::widgetA"] = widgetA, + ["NsRoot::widgetB"] = widgetB, + ["M::WidgetA"] = widgetADef, + ["M::WidgetB"] = widgetBDef + } + }; + } + + /// + /// Builds the same two-top-level-parts shape as , + /// but with both features and a connector between them nested as children of a common + /// NsRoot part def container — so the connector's own qualified name + /// (NsRoot::conn1) is itself a direct child of the exposed namespace, and the + /// container is scanned by ResolveTopLevelConnections via BuildDefinitionIndex + /// without itself qualifying as the diagram's single root (excluded from self-matching by + /// the NamespaceDirectChildren recursion kind, same as PublishingSubsystem + /// above). + /// + private static SysmlWorkspace BuildTwoTopLevelPartsWithConnectionWorkspace() + { + var widgetADef = new SysmlDefinitionNode { Name = "WidgetA", QualifiedName = "M::WidgetA", DefinitionKeyword = "part def" }; + var widgetBDef = new SysmlDefinitionNode { Name = "WidgetB", QualifiedName = "M::WidgetB", DefinitionKeyword = "part def" }; + var nsRoot = new SysmlDefinitionNode + { + Name = "NsRoot", + QualifiedName = "NsRoot", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "widgetA", QualifiedName = "NsRoot::widgetA", FeatureKeyword = "part", FeatureTyping = "WidgetA" }, + new SysmlFeatureNode { Name = "widgetB", QualifiedName = "NsRoot::widgetB", FeatureKeyword = "part", FeatureTyping = "WidgetB" }, + new SysmlConnectionNode + { + Name = "conn1", + QualifiedName = "NsRoot::conn1", + ConnectionKeyword = "connection", + EndpointA = "widgetA", + EndpointB = "widgetB" + } + ] + }; + return new SysmlWorkspace + { + Declarations = new Dictionary + { + ["NsRoot"] = nsRoot, + ["NsRoot::widgetA"] = nsRoot.Children[0], + ["NsRoot::widgetB"] = nsRoot.Children[1], + ["NsRoot::conn1"] = nsRoot.Children[2], + ["M::WidgetA"] = widgetADef, + ["M::WidgetB"] = widgetBDef + } + }; + } } From 5da9235b6d23aa728ed010e90312410b408b7844 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 16 Jul 2026 11:57:17 -0400 Subject: [PATCH 3/4] Document top-level scoped feature fallback for InterconnectionViewLayoutStrategy Updates the three companion artifacts for the InterconnectionViewLayoutStrategy feature that renders top-level exposed part features without a frame when no single root definition is found: - design doc: new remarks paragraph, new subsections for CollectTopLevelScopedParts/ResolveTopLevelConnections, BuildPartItem, and the parameterized LayOutInteriorWithConnections, plus an updated Error Handling section describing the new fallback path. - reqstream yaml: three new requirement entries (ScopedTopLevelFeatureFallback, ScopedTopLevelFeatureFallbackConnections, ScopedTopLevelFeatureFallbackEmptyCanvas) linked to the new regression tests. - verification doc: new verification-approach paragraph, acceptance criteria, and test-scenario table rows covering the new fallback. - user guide: notes that an expose scope naming no root definition is no longer always an empty Interconnection View diagram when the scope directly includes top-level part feature usages instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../interconnection-view-layout-strategy.md | 67 +++++++++++++++++-- .../interconnection-view-layout-strategy.yaml | 53 +++++++++++++++ docs/user_guide/introduction.md | 8 +++ .../interconnection-view-layout-strategy.md | 33 +++++++++ 4 files changed, 157 insertions(+), 4 deletions(-) diff --git a/docs/design/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md index 23f8c628..9e115bf6 100644 --- a/docs/design/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md @@ -32,7 +32,64 @@ be declared in, since composition structure and namespace/file organization are SysML v2), and assembles the root container box with the interior content nested as that box's own `Children` (mirroring the nesting `MakePartBox` already uses for a container part, so the root box is never a bare sibling of its own content) into the `LayoutTree`. -Returns a minimal 200×100 empty `LayoutTree` when no root or no parts are found. +When `FindRoot` selects no root, falls back to the **no-single-root scoped fallback** described +below when the resolved scope directly includes one or more top-level `part` feature usages; +otherwise returns a minimal 200×100 empty `LayoutTree`. + +###### No-single-root scoped fallback (`CollectTopLevelScopedParts`, `ResolveTopLevelConnections`) + +Not every resolved `expose` scope names a single `part def` worth treating as "the" subject of the +diagram: per SysML v2 spec §8.3.26.11 (an InterconnectionView's subject need not be one definition) +and §9.2.20.2.6 ("exposed features as nodes, nested features as nested nodes"), the exposed content +can be one or more concrete `part` feature usages directly, with no enclosing definition of its own +— e.g. `expose PublishingSubsystem::*;` where `PublishingSubsystem` is itself only a namespace-like +`part def` whose sole nested `part markdownFormatter : MarkdownFormatter;` is the only thing worth +drawing. Before this fallback existed, `FindRoot` returning `null` always produced a totally empty +canvas in this shape, even though the scope named something concrete. + +When `FindRoot` returns `null` and a scope is resolved, `BuildLayout` calls +`CollectTopLevelScopedParts(workspace, scope, theme, defsByName)`, which scans +`workspace.Declarations` for every non-standard-library `SysmlFeatureNode` with +`FeatureKeyword == "part"` whose qualified name satisfies `ExposeScopeResolver.IsInSubjectScope`, +excludes any matched feature that is itself nested (`"::"`-prefixed) under another matched +feature's own qualified name (it is already reachable as that ancestor's own nested part, so must +not also be duplicated as a separate top-level node), and builds a `PartItem` for each survivor via +`BuildPartItem` — the same container-vs-leaf recursion `CollectParts` uses for every other nested +part, extracted so the logic is never duplicated. When at least one top-level part is found, +`BuildPartIndex` and `ResolveTopLevelConnections` (a `ResolveConnections` analogue that scans every +definition's own connections, since a top-level connection may be declared inside any definition in +the workspace, keeping only a connection whose own qualified name is itself in scope and whose +endpoints both resolve into the top-level part set) resolve any connections between the top-level +parts, and `LayOutInteriorWithConnections` is called directly with `boxDepth: 0` and +`reserveTitleArea: false` — producing boxless top-level nodes placed side by side by the same +`LayeredPlacement.PlaceWithPorts` containment-packing algorithm used everywhere else, with no +enclosing frame/title reserved. The resulting `LayoutTree.Nodes` therefore holds the placed part +boxes (and any ports/lines) directly as top-level siblings, instead of the usual single root +container box. When `CollectTopLevelScopedParts` returns no parts (no scope, or a scope matching no +`part` feature), the original minimal 200×100 empty canvas is preserved unchanged. + +###### `BuildPartItem(feature, theme, depth, defsByName, visited)` + +Extracted from `CollectParts`'s per-feature loop body: resolves the feature's `FeatureTyping` +against `defsByName` via `TryResolveContainer`, recursing into `LayOutInterior` at `depth + 1` for a +container part (with the resolved child's qualified name added to a copy of `visited`, and +`scope: null` — nested composition structure is never re-scoped, matching `CollectParts`'s own +documented recursion behavior) or computing an intrinsic leaf size via `ComputePartSize` otherwise. +Both `CollectParts` (depth > 0 or an already-scope-filtered depth-0 feature) and +`CollectTopLevelScopedParts` (a scope-matched top-level feature, always at `depth: 0`) call this one +method, so the container-vs-leaf recursion is defined exactly once. + +###### `LayOutInteriorWithConnections(parts, pairs, theme, boxDepth, reserveTitleArea = true)` + +Places `parts` and routes `pairs` via `LayeredPlacement.PlaceWithPorts`, unchanged from before this +feature except for two now-parameterized behaviors that previously assumed an enclosing container +box always exists: `boxDepth` is stamped directly onto each placed part's own `LayoutBox.Depth` +(the existing `LayOutInterior` call site passes `depth + 1`, preserving today's exact depth +numbering; the no-single-root fallback passes `0` directly, since there is no enclosing container +box in that path at all), and `reserveTitleArea` (default `true`, unchanged for every existing +caller) gates whether a title band is reserved above the placed content — `false` only for the +boxless fallback, where there is no enclosing frame/title to make room for, so the returned size is +just the bounding box of the placed content plus normal padding on every side. ###### Recursive nested layout (`LayOutInterior`, `CollectParts`, `BuildDefinitionIndex`) @@ -184,9 +241,11 @@ container so every connector waypoint is enclosed, without ever moving a box. ##### Error Handling Null `context` or `options` arguments throw `ArgumentNullException`. The absence of an eligible -part definition or of nested parts is not an error: the method returns the minimal empty canvas. -Connectors that cannot be routed cleanly are still drawn; this strategy does not itself construct -`LayoutWarnings` diagnostics, so the returned `LayoutTree` carries no layout-quality warnings. +part definition or of nested parts is not an error: the method returns the minimal empty canvas, +unless the resolved scope directly includes one or more top-level `part` feature usages, in which +case the no-single-root scoped fallback renders those instead (see above). Connectors that cannot +be routed cleanly are still drawn; this strategy does not itself construct `LayoutWarnings` +diagnostics, so the returned `LayoutTree` carries no layout-quality warnings. ##### Dependencies diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.yaml index 45332395..539f09ab 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.yaml @@ -251,3 +251,56 @@ sections: part. tests: - InterconnectionView_BuildLayout_PartWithPorts_PortsNeverOverlapBoxTitleArea + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-ScopedTopLevelFeatureFallback + title: >- + When no candidate root definition is scope-relevant but the resolved expose scope + directly includes one or more top-level part feature usages, InterconnectionViewLayoutStrategy + shall render those features as boxless nodes side by side instead of an empty canvas, + recursing into each feature's own interior exactly as a normal nested container part + would. + justification: | + Per SysML v2 spec §8.3.26.11 and §9.2.20.2.6, an InterconnectionView's exposed content + need not be a single definition — a scope such as `expose PublishingSubsystem::*;` where + `PublishingSubsystem` is itself only a namespace-like `part def` with a single nested + `part` feature usage names something concrete to draw even though no single `part def` + qualifies as "the" root. Before this fallback, `FindRoot` returning no root always + produced a totally empty canvas in this shape, silently discarding a valid diagram. Boxless + side-by-side placement (reusing the same `LayeredPlacement.PlaceWithPorts` containment + packing every other case already uses) and reusing the same container-vs-leaf recursion + `CollectParts` uses for every other nested part (via the shared `BuildPartItem` helper) + avoids duplicating any layout or recursion logic for this fallback. + tests: + - InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NoRootDef_RendersTopLevelFeatureWithoutFrame + - InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_TwoTopLevelParts_ArrangesSideBySideNoFrame + - InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_TopLevelFeatureIsContainer_RecursesInterior + - InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NestedTopLevelFeatureExcluded_NotDuplicated + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-ScopedTopLevelFeatureFallbackConnections + title: >- + When two or more boxless top-level parts are connected by a connection whose own + qualified name is itself within the resolved expose scope, InterconnectionViewLayoutStrategy + shall draw a connector between them; otherwise no edges are drawn for the fallback. + justification: | + A connector between two top-level exposed features is a rare but valid shape a resolved + `expose` scope can name; if it is itself resolvable and in scope, it must be drawn so the + diagram does not silently omit a relationship the model actually declares. This reuses the + same endpoint-resolution logic (`ResolveEndpoint`) as every other connection in this + strategy. + tests: + - InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_ConnectionBetweenTopLevelFeatures_DrawsEdge + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-ScopedTopLevelFeatureFallbackEmptyCanvas + title: >- + When no candidate root definition is scope-relevant and the resolved expose scope + matches no part feature usage, InterconnectionViewLayoutStrategy shall return the + existing minimal empty canvas, unchanged. + justification: | + The no-single-root scoped fallback must never regress the pre-existing empty-canvas + behavior for a scope (or absence of scope) that genuinely names nothing concrete to + draw — for example a scope whose only member is a non-part feature. Preserving the exact + `new LayoutTree(200.0, 100.0, [])` result in that case keeps every other unit that + consumes an Interconnection View's `LayoutTree` unaffected. + tests: + - InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NoMatchingFeature_ReturnsMinimalCanvas + - InterconnectionView_BuildLayout_EmptyWorkspace_ReturnsMinimalCanvas diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 2593f80c..6f11fb85 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -237,6 +237,14 @@ children (parts, states, actions, or lifelines) to those within the resolved sco no `expose` statement (including the `--auto`-synthesized view) renders unchanged, exactly as before this scoping behavior was introduced, for every view kind. +For an Interconnection View specifically, a scope that names no single root definition is not +always an empty diagram: when the scope directly includes one or more concrete top-level `part` +feature usages instead — for example `expose Subsystem::*;` where `Subsystem` is itself only a +namespace-like `part def` whose only nested content is a single `part` feature usage, so no +single `part def` qualifies as "the" root — those feature usages render directly, side by side, +with no enclosing frame around them. A scope that matches neither a root definition nor any +top-level `part` feature usage still renders the empty diagram described above. + Named `view Name { ... }` usages (not just `view def` declarations) are also now recognized as their own renderable declarations: a workspace containing both `view def` declarations and named `view` usages surfaces both kinds as views the `render` command discovers and renders. diff --git a/docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md index 96440e41..ac8977df 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md @@ -27,6 +27,19 @@ heuristic. A further test confirms every left/right port sits below its own box' reservation activates for it. No mocking is required; the strategy depends only on the in-memory model, `LayeredPlacement`, and render options. +A dedicated set of tests exercises the no-single-root scoped fallback: a workspace shaped exactly +like the reported bug (a namespace-like `part def` whose only nested content is a single `part` +feature usage, exposed via `expose Namespace::*;`) asserts the previously-empty canvas now renders +that feature directly, with no frame for the enclosing namespace anywhere in the tree; a two-sibling +variant asserts two independent top-level parts render as two non-overlapping top-level boxes; a +container-typed top-level feature variant asserts the recursion into its own nested parts still +fires (proving `BuildPartItem` is shared, not duplicated); a connection-between-top-level-parts +variant asserts a connector is drawn when the connection's own qualified name is itself in scope; +and a nested-qualified-name variant asserts a matched feature nested under another matched +feature's own qualified name is excluded from the top-level set (not duplicated). A final +regression test pins down the preserved empty-canvas fallback when the scope matches no `part` +feature at all. + ##### Test Environment Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or @@ -85,6 +98,20 @@ configuration are required beyond a standard .NET SDK installation. connection count — the layered algorithm's automatic title-vs-side-port reservation, activated by flagging every part node `HasLabel: true, HasKeyword: true`, keeps ports clear of the box's own "«keyword» / name : type" header row. +- When `FindRoot` selects no root but the resolved `expose` scope directly includes a top-level + `part` feature usage (e.g. `expose Namespace::*;` where `Namespace` is only a `part def` with one + nested `part`), that feature renders directly as a boxless node — `LayoutTree.Nodes` holds exactly + one box, with no frame labeled for the enclosing namespace anywhere in the tree. +- Two independent top-level scoped parts render as two non-overlapping top-level boxes with no + wrapping frame. +- A top-level scoped feature that is itself typed by a container definition still recurses into its + own nested parts, which appear as that top-level box's own `Children`. +- A connection between two top-level scoped parts is drawn as a connector line when the connection's + own qualified name is itself within the resolved scope. +- A matched top-level feature whose qualified name is nested under another matched feature's own + qualified name is excluded from the top-level set (not duplicated as its own separate node). +- When the resolved `expose` scope matches no `part` feature usage at all, the pre-existing minimal + empty canvas is returned unchanged. ##### Test Scenarios @@ -114,3 +141,9 @@ configuration are required beyond a standard .NET SDK installation. | `InterconnectionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | | `InterconnectionView_BuildLayout_ExposeInnerPartOfNestedDefinition_SelectsNestedDefinitionNotAncestor` | Nested wins | | `InterconnectionView_BuildLayout_ExposeBothSameDepthSiblings_ScoreBreaksTieNotLength` | Score breaks the tie | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NoRootDef_RendersTopLevelFeatureWithoutFrame` | Boxless single top-level feature, no frame | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_TwoTopLevelParts_ArrangesSideBySideNoFrame` | Two non-overlapping top-level boxes, no frame | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_TopLevelFeatureIsContainer_RecursesInterior` | Container recursion reused for top-level fallback | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NoMatchingFeature_ReturnsMinimalCanvas` | Empty-canvas fallback preserved | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_ConnectionBetweenTopLevelFeatures_DrawsEdge` | Connector between top-level parts | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NestedTopLevelFeatureExcluded_NotDuplicated` | Nested match excluded from top-level set | From b6c21f2e254ff0e09da794fe32e0456632c64492 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 16 Jul 2026 12:15:33 -0400 Subject: [PATCH 4/4] Fix lint: add boxless to cspell dictionary, shorten long table rows - Add 'boxless' to .cspell.yaml words list next to 'frameless' - Shorten 6 over-length (MD013 >120 chars) assertion cells in the interconnection-view-layout-strategy verification doc table, keeping test names and table structure unchanged Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 1 + .../internal/interconnection-view-layout-strategy.md | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.cspell.yaml b/.cspell.yaml index e2613f40..8896c91b 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -30,6 +30,7 @@ words: - docversion - errorlevel - fileassert + - boxless - frameless - Fruchterman - Hanan diff --git a/docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md index ac8977df..fa7583ff 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.md @@ -141,9 +141,9 @@ configuration are required beyond a standard .NET SDK installation. | `InterconnectionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | | `InterconnectionView_BuildLayout_ExposeInnerPartOfNestedDefinition_SelectsNestedDefinitionNotAncestor` | Nested wins | | `InterconnectionView_BuildLayout_ExposeBothSameDepthSiblings_ScoreBreaksTieNotLength` | Score breaks the tie | -| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NoRootDef_RendersTopLevelFeatureWithoutFrame` | Boxless single top-level feature, no frame | -| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_TwoTopLevelParts_ArrangesSideBySideNoFrame` | Two non-overlapping top-level boxes, no frame | -| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_TopLevelFeatureIsContainer_RecursesInterior` | Container recursion reused for top-level fallback | -| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NoMatchingFeature_ReturnsMinimalCanvas` | Empty-canvas fallback preserved | -| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_ConnectionBetweenTopLevelFeatures_DrawsEdge` | Connector between top-level parts | -| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NestedTopLevelFeatureExcluded_NotDuplicated` | Nested match excluded from top-level set | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NoRootDef_RendersTopLevelFeatureWithoutFrame` | Alone | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_TwoTopLevelParts_ArrangesSideBySideNoFrame` | 2 boxes | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_TopLevelFeatureIsContainer_RecursesInterior` | Nested | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NoMatchingFeature_ReturnsMinimalCanvas` | No match | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_ConnectionBetweenTopLevelFeatures_DrawsEdge` | Linked | +| `InterconnectionView_BuildLayout_ExposeNamespaceDirectChildren_NestedTopLevelFeatureExcluded_NotDuplicated` | Dedup |