Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ words:
- docversion
- errorlevel
- fileassert
- boxless
- frameless
- Fruchterman
- Hanan
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions docs/user_guide/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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` | 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 |
Loading
Loading