From 2e65eead2d13c30b9e2b7383c8832144ad5e0b23 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 6 Jul 2026 19:13:05 -0400 Subject: [PATCH 1/4] Add expose-based scoping to remaining layout strategies Extend expose-based content scoping (previously only implemented for GeneralViewLayoutStrategy) to the other six layout strategies: InterconnectionViewLayoutStrategy, StateTransitionViewLayoutStrategy, ActionFlowViewLayoutStrategy, SequenceViewLayoutStrategy, GridViewLayoutStrategy, and BrowserViewLayoutStrategy. - Extract shared ExposeScopeResolver (ResolveExposedScope, IsInSubjectScope, IsRootRelevantToScope) from GeneralViewLayoutStrategy into a reusable internal static class; GeneralViewLayoutStrategy is refactored to use it with no behavior change. - Grid View and Browser View filter their definition collection directly, mirroring General View's approach. - Interconnection/StateTransition/ActionFlow/Sequence restrict root selection via IsRootRelevantToScope and narrow their child collections; graph edges (connections/messages) with a filtered-out endpoint are dropped rather than left dangling. - Add regression tests per strategy covering scope narrowing, union of multiple expose targets, no-expose-statement (including --auto/null ViewNode) unchanged rendering, and usage-to-type fallback where applicable, plus dedicated ExposeScopeResolverTests. - Update ROADMAP.md, design docs, reqstream requirements (atomic, split per behavior), verification docs, .reviewmark.yaml, and the stale "Only the General View..." caveat in docs/user_guide/introduction.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .reviewmark.yaml | 14 + ROADMAP.md | 22 -- docs/design/introduction.md | 2 + .../sysml2-tools-core/layout/internal.md | 17 +- .../action-flow-view-layout-strategy.md | 42 ++- .../internal/browser-view-layout-strategy.md | 37 ++- .../layout/internal/expose-scope-resolver.md | 83 +++++ .../internal/general-view-layout-strategy.md | 52 +-- .../internal/grid-view-layout-strategy.md | 34 +- .../interconnection-view-layout-strategy.md | 45 ++- .../internal/sequence-view-layout-strategy.md | 55 +++- .../state-transition-view-layout-strategy.md | 50 ++- .../action-flow-view-layout-strategy.yaml | 35 +++ .../browser-view-layout-strategy.yaml | 29 ++ .../internal/expose-scope-resolver.yaml | 80 +++++ .../internal/grid-view-layout-strategy.yaml | 27 ++ .../interconnection-view-layout-strategy.yaml | 33 ++ .../sequence-view-layout-strategy.yaml | 49 +++ ...state-transition-view-layout-strategy.yaml | 35 +++ docs/user_guide/introduction.md | 14 +- .../action-flow-view-layout-strategy.md | 19 ++ .../internal/browser-view-layout-strategy.md | 13 + .../layout/internal/expose-scope-resolver.md | 60 ++++ .../internal/grid-view-layout-strategy.md | 13 + .../interconnection-view-layout-strategy.md | 21 ++ .../internal/sequence-view-layout-strategy.md | 27 ++ .../state-transition-view-layout-strategy.md | 19 ++ .../Internal/ActionFlowViewLayoutStrategy.cs | 46 ++- .../Internal/BrowserViewLayoutStrategy.cs | 13 +- .../Layout/Internal/ExposeScopeResolver.cs | 109 +++++++ .../Internal/GeneralViewLayoutStrategy.cs | 72 +---- .../Layout/Internal/GridViewLayoutStrategy.cs | 15 +- .../InterconnectionViewLayoutStrategy.cs | 47 ++- .../Internal/SequenceViewLayoutStrategy.cs | 44 ++- .../StateTransitionViewLayoutStrategy.cs | 43 ++- .../ActionFlowViewLayoutStrategyTests.cs | 216 +++++++++++++ .../BrowserAndGridViewLayoutStrategyTests.cs | 266 ++++++++++++++++ .../Layout/ExposeScopeResolverTests.cs | 180 +++++++++++ .../InterconnectionViewLayoutStrategyTests.cs | 241 ++++++++++++++ .../Layout/SequenceViewLayoutStrategyTests.cs | 296 +++++++++++++++++- .../StateTransitionViewLayoutStrategyTests.cs | 214 +++++++++++++ 41 files changed, 2498 insertions(+), 231 deletions(-) create mode 100644 docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md create mode 100644 docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml create mode 100644 docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md create mode 100644 src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs create mode 100644 test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 3fc1314e..72f48c64 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -612,6 +612,20 @@ reviews: - "src/DemaConsulting.SysML2Tools.Core/Layout/Internal/LayoutWarnings.cs" - "test/DemaConsulting.SysML2Tools.Tests/Layout/LayoutWarningsTests.cs" + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver + title: Review that DemaConsulting.SysML2Tools Layout Internal ExposeScopeResolver Implementation is Correct + context: + - docs/design/sysml2-tools-core.md + - docs/reqstream/sysml2-tools-core.yaml + - docs/design/sysml2-tools-core/layout.md + - docs/design/sysml2-tools-core/layout/internal.md + paths: + - "docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml" + - "docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md" + - "docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md" + - "src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs" + - "test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs" + - id: SysML2Tools-Core-Rendering-DiagramRenderer title: Review that DemaConsulting.SysML2Tools Rendering DiagramRenderer Implementation is Correct context: diff --git a/ROADMAP.md b/ROADMAP.md index 050b96a0..b985512c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,28 +95,6 @@ new expression-evaluation component; `GeneralViewLayoutStrategy` filter applicat **Visual gate:** a view with a `filter [];` statement renders only the elements satisfying the predicate, with no "not yet evaluated" warning. -### Expose-based scoping for the remaining layout strategies - -`GeneralViewLayoutStrategy` implements `expose`-based subject-scoping (containment-subtree -filtering driven by a view's `expose` body statements — the only content-scoping mechanism a -view has), but `InterconnectionView`, `StateTransitionView`, `ActionFlowView`, `SequenceView`, -`GridView`, and `BrowserView` layout strategies do not yet honor `ViewContext.ViewNode`'s -`Expose` edges and continue to render their full applicable scope regardless of a view's -declared `expose` statements. - -- Extend the same `ResolveExposedScope`/`IsInSubjectScope` containment-subtree idiom (or a - shared helper extracted from `GeneralViewLayoutStrategy`) to each of the six remaining layout - strategies, respecting the "no `Expose` edge → render everything unchanged" fallback used by - `GeneralViewLayoutStrategy`. -- Add regression tests per strategy mirroring `GeneralViewLayoutStrategyTests`'s expose-scoping, - expose-union, and no-expose-statement-regression scenarios. - -**Scope:** `InterconnectionViewLayoutStrategy`, `StateTransitionViewLayoutStrategy`, -`ActionFlowViewLayoutStrategy`, `SequenceViewLayoutStrategy`, `GridViewLayoutStrategy`, -`BrowserViewLayoutStrategy`; corresponding test files. -**Visual gate:** each of the six views renders a scoped diagram when its view declares an -`expose <...>;` statement naming a resolvable target, unchanged when it does not. - ### Support selecting rendering style via `render ;` A view's `render ;` member names a rendering style/format usage per the SysML v2 diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 5b220f3b..012044e2 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -87,6 +87,8 @@ system, subsystem, and unit levels: - **GridViewLayoutStrategy** (Unit) — specialization/relationship matrix - **BrowserViewLayoutStrategy** (Unit) — indented membership tree - **LayoutWarnings** (Unit) — builder for layout diagnostic warning messages + - **ExposeScopeResolver** (Unit) — shared helper resolving a view's `expose`-statement + qualified-name containment-subtree scope, used by every strategy above - **LayeredPlacement** (Unit) — thin helper that adapts the off-the-shelf `DemaConsulting.Rendering.Layout` layered algorithm, returning placed rectangles and routed polylines to the strategies diff --git a/docs/design/sysml2-tools-core/layout/internal.md b/docs/design/sysml2-tools-core/layout/internal.md index 592f8a8e..7b11729d 100644 --- a/docs/design/sysml2-tools-core/layout/internal.md +++ b/docs/design/sysml2-tools-core/layout/internal.md @@ -18,8 +18,8 @@ The subsystem contains one strategy per supported view type: | `ActionFlowViewLayoutStrategy` | Lays out actions top-to-bottom with start/done markers and successions | The subsystem also contains the `BrowserViewLayoutStrategy`, `GridViewLayoutStrategy`, and -`SequenceViewLayoutStrategy` strategies and the `LayoutWarnings` helper, each documented in its -own chapter. +`SequenceViewLayoutStrategy` strategies, the shared `ExposeScopeResolver` helper, and the +`LayoutWarnings` helper, each documented in its own chapter. #### Interfaces @@ -31,9 +31,10 @@ The renderers see only the returned tree. #### Design Each strategy follows the same shape: collect the relevant model elements (excluding -standard-library declarations), compute an intrinsic size for each box, use arithmetic placement -or delegate geometry through `LayeredPlacement`, and build the `LayoutNode` tree. When a -connector cannot be routed without crossing a box, the strategy records a layout warning through -`LayoutWarnings` rather than silently producing a misleading diagram. A view with no relevant -elements returns a minimal empty canvas. The detailed mapping and heuristics of each strategy are -described in its own unit chapter. +standard-library declarations), restrict that collection to the view's resolved `expose` scope +(via the shared `ExposeScopeResolver`) when one applies, compute an intrinsic size for each box, +use arithmetic placement or delegate geometry through `LayeredPlacement`, and build the +`LayoutNode` tree. When a connector cannot be routed without crossing a box, the strategy records +a layout warning through `LayoutWarnings` rather than silently producing a misleading diagram. A +view with no relevant elements returns a minimal empty canvas. The detailed mapping and +heuristics of each strategy are described in its own unit chapter. diff --git a/docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md index 87732d7a..64b3356b 100644 --- a/docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md @@ -18,16 +18,22 @@ its computed box size; successions are carried as `(int From, int To)` index pai ###### `BuildLayout(ViewContext context, RenderOptions options)` -Entry point. Selects the root definition via `FindRoot`, collects its actions, resolves its -successions, lays the actions out in layers, adds the succession edges and the start/done markers, -and assembles the tree. Returns a minimal 200×100 empty `LayoutTree` when no root or no actions -are found. +Entry point. Resolves the view's `expose` scope via `ExposeScopeResolver.ResolveExposedScope`, +selects the root definition via `FindRoot(workspace, scope)`, collects its actions via +`CollectActions(root, theme, scope)`, resolves its successions, lays the actions out in layers, +adds the succession edges and the start/done markers, and assembles the tree. Returns a minimal +200×100 empty `LayoutTree` when no root or no actions are found. -###### `FindRoot(workspace)` and `CollectActions(root, theme)` +###### `FindRoot(workspace, scope)` and `CollectActions(root, theme, scope)` `FindRoot` chooses the non-standard-library definition that scores highest on successions (then -actions). `CollectActions` gathers the declared `action` usages and any action named only by a -succession endpoint, building a name → index lookup. +actions), restricted — when a scope is resolved — to candidates for which +`ExposeScopeResolver.IsRootRelevantToScope` returns `true`. `CollectActions` gathers the declared +`action` usages, excluding — when a scope is resolved — any declared action feature whose +qualified name fails `ExposeScopeResolver.IsInSubjectScope`; it then adds any additional action +named only by a succession endpoint **unconditionally** (this second pass has no independent +qualified name of its own to scope against, since it exists solely because a succession names it), +building a name → index lookup. ###### `ResolveSuccessions(root, index)` @@ -58,6 +64,25 @@ and returns the number of successions whose polyline crosses a non-endpoint acti centred over the actions with no incoming edge and a bullseye done marker centred under the actions with no outgoing edge, joining each with a solid filled-arrow flow line. +##### Expose Scoping + +Because this strategy renders exactly one selected root's actions, scoping restricts **which +root is selected** and then narrows **which of that root's actions are shown**, mirroring +`StateTransitionViewLayoutStrategy`'s approach. `FindRoot` only considers candidates +`ExposeScopeResolver.IsRootRelevantToScope` accepts, so exposing the current heuristic root +itself, an inner action of it, or a definition that itself contains the heuristic default all +correctly select a root, while exposing an unrelated definition yields no root and thus the +minimal empty canvas. `CollectActions` then narrows the selected root's own **declared** action +features to those within the resolved scope; however, any declared-but-excluded action that is +still referenced by an in-scope succession is transparently re-added by the unconditional +succession-endpoint pass, since that pass has no independent qualified name to filter against — so +expose-scoping only reliably drops an action that is genuinely isolated (never referenced by any +succession of the selected root). `ResolveSuccessions`'s existing name-lookup approach naturally +omits any succession whose endpoint action was never added — no new edge-side logic was required. +A view with no `expose` statement (including the synthesized `--auto` view, whose `ViewNode` is +`null`) resolves no scope, so `FindRoot` considers every candidate and `CollectActions` keeps +every action, unchanged from the pre-scoping behavior. + ##### Error Handling Null `context` or `options` arguments throw `ArgumentNullException`. The absence of an eligible @@ -73,6 +98,9 @@ surfaced through `LayoutWarnings`. - `LayeredPlacement` (Layout Internal subsystem) — top-to-bottom placement and orthogonal routing through `DemaConsulting.Rendering.Layout`. - `StdlibFilter` (Rendering Internal subsystem) — standard-library exclusion. +- `ExposeScopeResolver` (Layout Internal subsystem) — `ResolveExposedScope`, + `IsRootRelevantToScope`, and `IsInSubjectScope` supply the shared `expose`-scoping used by + `BuildLayout`, `FindRoot`, and `CollectActions`. - `SysmlWorkspace`, `SysmlDefinitionNode`, `SysmlFeatureNode`, `SysmlTransitionNode` (Semantic subsystem) — model input. - `LayoutWarnings` (Layout Internal subsystem) — crossing-warning construction. - The `LayoutTree`, `LayoutBox`, `LayoutBadge`, and `LayoutLine` data types diff --git a/docs/design/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md index 87913f41..153f90fe 100644 --- a/docs/design/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md @@ -20,20 +20,39 @@ holding a node's qualified name, display label, optional keyword, and child node Walks the membership forest and emits rows: -1. **Forest construction.** `BuildForest` takes the non-stdlib declarations in deterministic - (ordinal qualified-name) order so parents precede children, and links each element to the parent - identified by the prefix before its last `::` separator; elements with no known parent become - roots. -2. **Recursive emission.** `EmitNode` lays out each row left-to-right at an X derived from its depth +1. **Scope resolution.** `ExposeScopeResolver.ResolveExposedScope` resolves the view's `expose` + scope once (or `null` when none applies). +2. **Forest construction.** `BuildForest` takes the non-stdlib declarations in deterministic + (ordinal qualified-name) order so parents precede children — additionally excluding, when a + scope was resolved, any element whose qualified name is not within it per + `ExposeScopeResolver.IsInSubjectScope` — and links each remaining element to the parent + identified by the prefix before its last `::` separator; elements with no known parent (whether + because they are genuine workspace roots, or because scoping excluded their would-be parent) + become roots. +3. **Recursive emission.** `EmitNode` lays out each row left-to-right at an X derived from its depth times a fixed indentation, advancing a shared Y cursor downward. Each row becomes a `LayoutBox` whose label combines the element keyword and simple name and whose width fits the label. -3. **Connectors.** For every non-root row a `LayoutLine` is emitted from a vertical stem dropped from +4. **Connectors.** For every non-root row a `LayoutLine` is emitted from a vertical stem dropped from the parent row down to the child's vertical centre and across to the child box, so the connector never crosses the parent's own box or text. -4. **Canvas sizing.** The overall width follows the right-most box and the height follows the final +5. **Canvas sizing.** The overall width follows the right-most box and the height follows the final Y cursor. -When there are no user-defined elements, a minimal empty `LayoutTree` with no nodes is returned. +When there are no user-defined elements (either because the workspace is empty or because scoping +excludes every element), a minimal empty `LayoutTree` with no nodes is returned. + +##### Expose Scoping + +`BuildForest` is the only place scoping applies: it is a direct, workspace-wide filter with no +single-root heuristic to restrict, so a resolved `expose` scope simply narrows the forest to the +elements within the exposed targets' containment subtrees (plus, via +`ExposeScopeResolver.ResolveExposedScope`'s usage-to-type fallback, an exposed feature usage's own +type). Because the parent-lookup step runs only over the narrowed set, an exposed target whose own +parent was filtered out is promoted to a forest root, so the diagram becomes one or more subtrees +rooted at the exposed target(s) rather than a single truncated tree. Multiple `expose` targets union +their subtrees, since `IsInSubjectScope` matches against every resolved subject. A view with no +`expose` statement (including the synthesized `--auto` view, whose `ViewNode` is `null`) resolves no +scope and renders the full membership forest, unchanged from the pre-scoping behavior. ##### Error Handling @@ -49,6 +68,8 @@ throw: the strategy returns an empty diagram rather than failing. - `SysmlWorkspace`, `SysmlNode`, `SysmlPackageNode`, `SysmlDefinitionNode`, `SysmlFeatureNode`, and `SysmlViewNode` (Semantic subsystem). - `StdlibFilter` (Rendering Internal subsystem) — standard-library exclusion. +- `ExposeScopeResolver` (Layout Internal subsystem) — `ResolveExposedScope` and + `IsInSubjectScope` supply the shared `expose`-scoping used by `BuildForest`. ##### Callers diff --git a/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md b/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md new file mode 100644 index 00000000..46ef5e4b --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md @@ -0,0 +1,83 @@ +#### ExposeScopeResolver + +##### Purpose + +`ExposeScopeResolver` is the single shared helper resolving the qualified-name containment-subtree +scope a view's `expose` statements restrict a diagram to. Every `ILayoutStrategy` implementation +(`GeneralViewLayoutStrategy`, `GridViewLayoutStrategy`, `BrowserViewLayoutStrategy`, +`InterconnectionViewLayoutStrategy`, `StateTransitionViewLayoutStrategy`, +`ActionFlowViewLayoutStrategy`, and `SequenceViewLayoutStrategy`) calls into this unit instead of +each maintaining its own copy, so every view kind honors `expose` scoping identically. + +##### Data Model + +`ExposeScopeResolver` is a static class with no instance state; all input arrives through method +parameters. It has no private records or fields — every method is a pure function over a +`SysmlWorkspace` and/or a list of qualified-name "subjects". + +##### Key Methods + +###### `ResolveExposedScope(SysmlWorkspace workspace, SysmlViewNode? viewNode)` + +Resolves the qualified-name containment-subtree scope a view's `expose` statements restrict the +diagram to — the only content-scoping mechanism a view has (`render ;` names a +rendering style/format, e.g. `asTreeDiagram`/`asElementTable`, per the SysML v2 grammar, never +content, so `RenderTargetName` never affects this decision). Returns `null` — meaning "render +everything", the pre-scoping behavior — whenever the view has no resolved `Expose`-kind +`ResolvedEdges` entries: covering a `null` `viewNode` (the `--auto` synthesized view, which never +carries expose/render/filter data), a view with no `expose` statement, and a view whose every +`expose` entry failed to resolve, uniformly. Otherwise, for each resolved `Expose` edge's target +qualified name, adds that name to the scope; when `workspace.Declarations` resolves the target to +a `SysmlFeatureNode` (a usage, e.g. `part myVehicle : Vehicle;`) rather than a +`SysmlDefinitionNode`, additionally resolves the usage's own `Typing`-kind `ResolvedEdges` entry +(if any) and adds *that* type's qualified name to the scope too — the usage-to-type fallback for +the containment gap where a usage's own (typically empty) subtree would otherwise silently +produce zero content. + +###### `IsInSubjectScope(string qualifiedName, IReadOnlyList subjects)` + +Returns `true` when `qualifiedName` equals one of `subjects` or lies within one of their +containment subtrees (a `"{subject}::"` prefix match) — the same qualified-name-prefix idiom +`StdlibFilter.IsStdlibElement` already uses for stdlib-prefix matching. Used by every strategy to +decide whether a candidate element belongs in a scoped diagram. + +###### `IsRootRelevantToScope(string candidateQualifiedName, IReadOnlyList subjects)` + +Returns `true` when `candidateQualifiedName` (a candidate single-root diagram root, e.g. the +definition `InterconnectionViewLayoutStrategy`, `StateTransitionViewLayoutStrategy`, +`ActionFlowViewLayoutStrategy`, or `SequenceViewLayoutStrategy` would otherwise pick by its own +heuristic) is related to the resolved `expose` scope in `subjects`, in either containment +direction: the candidate itself is an exposed subject, the candidate lies within an exposed +subject's containment subtree, or an exposed subject lies within the candidate's own containment +subtree (the common "expose an inner state/action/part/lifeline of the root" case). Used only by +the four single-root strategies to restrict their heuristic root selection to one relevant to the +resolved scope. + +##### Error Handling + +N/A — none of the three methods validate arguments or throw; a `null` `viewNode` and an empty or +non-existent `subjects`/workspace-declaration lookup are all treated as ordinary "no match" or +"no scope" cases, not error conditions. + +##### Dependencies + +- `SysmlWorkspace`, `SysmlViewNode`, `SysmlFeatureNode`, `SysmlDefinitionNode`, `SysmlEdge`, + `SysmlEdgeKind` (Semantic subsystem) — the workspace and view model read by + `ResolveExposedScope`. +- `StdlibFilter` (Rendering Internal subsystem) — referenced only in documentation, as the origin + of the qualified-name-prefix idiom `IsInSubjectScope` reuses. +- `ILayoutStrategy` (Rendering subsystem) — referenced only in documentation, identifying the + callers this unit exists to serve. + +##### Callers + +- `GeneralViewLayoutStrategy` calls all three methods (moved here verbatim from its own former + private copies of `ResolveExposedScope` and `IsInSubjectScope`). +- `GridViewLayoutStrategy` and `BrowserViewLayoutStrategy` call `ResolveExposedScope` and + `IsInSubjectScope` to filter their workspace-wide definition/tree-node collections directly (no + single-root heuristic, so `IsRootRelevantToScope` does not apply). +- `InterconnectionViewLayoutStrategy`, `StateTransitionViewLayoutStrategy`, + `ActionFlowViewLayoutStrategy`, and `SequenceViewLayoutStrategy` call `ResolveExposedScope` to + compute the scope, `IsRootRelevantToScope` to restrict their heuristic root selection to a root + relevant to that scope, and `IsInSubjectScope` to filter the child elements (parts, states, + actions, lifelines) collected from the selected root. diff --git a/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md index d965467a..1b14989f 100644 --- a/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md @@ -34,13 +34,15 @@ from the structural relationships. ###### `BuildLayout(ViewContext context, RenderOptions options)` -Entry point. First resolves the view's exposed-name scope via `ResolveExposedScope`, -then calls `CollectDefinitions` to gather user definitions restricted to that scope (or every -definition when no scope applies); returns a minimal 200×100 empty `LayoutTree` when none are -found. Otherwise groups the definitions by package with `GroupByPackage`, resolves the -specialization/membership/attribute-typing relationships into qualified-name edges with -`BuildModelEdges`, builds the single input `LayoutGraph` with `BuildGraph`, and places the whole -graph with one `HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment"))` +Entry point. First resolves the view's exposed-name scope via the shared +`ExposeScopeResolver.ResolveExposedScope` (see +[ExposeScopeResolver](expose-scope-resolver.md)), then calls `CollectDefinitions` to gather user +definitions restricted to that scope (or every definition when no scope applies); returns a +minimal 200×100 empty `LayoutTree` when none are found. Otherwise groups the definitions by +package with `GroupByPackage`, resolves the specialization/membership/attribute-typing +relationships into qualified-name edges with `BuildModelEdges`, builds the single input +`LayoutGraph` with `BuildGraph`, and places the whole graph with one +`HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment"))` call — passing the desired root-scope leaf algorithm through the options parameter (not `graph.Set(CoreOptions.Algorithm, …)`) so a caller going through `LayoutEngine.Layout(graph)` later is never misled into skipping the hierarchical engine. When any package folder was depth-truncated, @@ -50,37 +52,14 @@ box. Finally, when `context.ViewNode?.FilterExpressionText` is non-null, attache tree's `Warnings` via the `LayoutTree with { Warnings = … }` record-copy idiom, leaving the resolved (unfiltered) scope's content unchanged. -###### `ResolveExposedScope(SysmlWorkspace workspace, SysmlViewNode? viewNode)` - -Resolves the qualified-name containment-subtree scope a view's `expose` statements restrict the -diagram to — the only content-scoping mechanism a view has (`render ;` names a -rendering style/format, e.g. `asTreeDiagram`/`asElementTable`, per the SysML v2 grammar, never -content, so `RenderTargetName` never affects this decision). Returns `null` — meaning "render -everything", byte-identical to the pre-scoping behavior — whenever the view has no resolved -`Expose`-kind `ResolvedEdges` entries: covering a `null` `viewNode` (the `--auto` synthesized -view, which never carries expose/render/filter data), a view with no `expose` statement, and a -view whose every `expose` entry failed to resolve, uniformly. Otherwise, for each resolved -`Expose` edge's target qualified name, adds that name to the scope; when -`workspace.Declarations` resolves the target to a `SysmlFeatureNode` (a usage, e.g. -`part myVehicle : Vehicle;`) rather than a `SysmlDefinitionNode`, additionally resolves the -usage's own `Typing`-kind `ResolvedEdges` entry (if any) and adds *that* type's qualified name to -the scope too — the usage-to-type resolution fix for the containment gap where a usage's own -(typically empty) subtree would otherwise silently produce zero content. - -###### `IsInSubjectScope(qualifiedName, subjects)` - -Returns `true` when `qualifiedName` equals one of `subjects` or lies within one of their -containment subtrees (a `"{subject}::"` prefix match) — the same qualified-name-prefix idiom -`StdlibFilter.IsStdlibElement` already uses for stdlib-prefix matching. Generic over any subject -list; unchanged by this fix. - ###### `CollectDefinitions(workspace, theme, scope)` Iterates `workspace.Declarations`, keeping each `SysmlDefinitionNode` that is not a standard-library element (per `StdlibFilter.IsStdlibElement`) and, when `scope` is non-null, is -within `scope` per `IsInSubjectScope`. For each kept definition it builds the compartments from -the owned usage features (grouped by keyword, each formatted as a `name : Type [n]` row), collects -the typed memberships, and computes the box size from the title and the longest compartment row. +within `scope` per `ExposeScopeResolver.IsInSubjectScope`. For each kept definition it builds the +compartments from the owned usage features (grouped by keyword, each formatted as a +`name : Type [n]` row), collects the typed memberships, and computes the box size from the title +and the longest compartment row. ###### `GroupByPackage(defs)` @@ -157,8 +136,9 @@ produces valid geometry, so no crossing warnings are emitted. - `BoxMetrics` (`DemaConsulting.Rendering.Abstractions`) — box title-area and folder-tab geometry. - `StdlibFilter` (Rendering Internal subsystem) — standard-library exclusion. - `SysmlWorkspace`, `SysmlDefinitionNode`, `SysmlFeatureNode` (Semantic subsystem) — model input. -- `SysmlViewNode`, `SysmlEdge`, `SysmlEdgeKind` (Semantic subsystem) — a view's resolved - `expose` data, read by `ResolveExposedScope`. +- `ExposeScopeResolver` (Layout Internal subsystem) — `ResolveExposedScope` and + `IsInSubjectScope` supply the shared `expose`-scoping used by `BuildLayout` and + `CollectDefinitions`. - `LayoutWarnings` (Layout Internal subsystem) — `ForUnevaluatedFilter` supplies the "parsed but not yet evaluated" filter-expression warning text. - The `LayoutTree`, `LayoutBox`, `LayoutCompartment`, `LayoutLine`, `LayoutLabel`, and `Point2D` data diff --git a/docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md index ae5ec586..59285fd1 100644 --- a/docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md @@ -20,20 +20,36 @@ single `LayoutGrid` of `LayoutGridRow` and `LayoutGridCell` values. Builds the matrix: -1. **Definition collection.** `CollectDefinitions` gathers the non-stdlib definitions in - deterministic (ordinal qualified-name) order. An index map from simple name to column is built - from them. -2. **Sizing.** Row height derives from the body font size and label padding; the header column width +1. **Scope resolution.** `ExposeScopeResolver.ResolveExposedScope` resolves the view's `expose` + scope once (or `null` when none applies). +2. **Definition collection.** `CollectDefinitions` gathers the non-stdlib definitions in + deterministic (ordinal qualified-name) order, additionally excluding — when a scope was + resolved — any definition whose qualified name is not within it per + `ExposeScopeResolver.IsInSubjectScope`. An index map from simple name to column is built from + the (possibly narrowed) set. +3. **Sizing.** Row height derives from the body font size and label padding; the header column width and the data column width derive from `MaxLabelWidth`, the widest definition label. -3. **Header row.** An empty corner cell is followed by one centered header cell per definition. -4. **Data rows.** For each row definition, a left-aligned header cell carries its name, then one +4. **Header row.** An empty corner cell is followed by one centered header cell per definition. +5. **Data rows.** For each row definition, a left-aligned header cell carries its name, then one cell per column carries the mark where `ResolveSupertypeIndices` reports that the row definition specializes the column definition (matching supertype references to columns by simple name) and an empty cell otherwise. -5. **Assembly.** The rows are wrapped in a `LayoutGrid` positioned with a small padding offset, and +6. **Assembly.** The rows are wrapped in a `LayoutGrid` positioned with a small padding offset, and the overall canvas width and height are computed from the column counts and sizes. -When there are no user-defined definitions, a minimal empty `LayoutTree` with no nodes is returned. +When there are no user-defined definitions (either because the workspace is empty or because +scoping excludes every definition), a minimal empty `LayoutTree` with no nodes is returned. + +##### Expose Scoping + +`CollectDefinitions` is the only place scoping applies: it is a direct, workspace-wide filter with +no single-root heuristic to restrict, so a resolved `expose` scope simply narrows the matrix to the +definitions within the exposed targets' containment subtrees (plus, via +`ExposeScopeResolver.ResolveExposedScope`'s usage-to-type fallback, an exposed feature usage's own +type). Multiple `expose` targets union their subtrees, since `IsInSubjectScope` matches against +every resolved subject. A view with no `expose` statement (including the synthesized `--auto` +view, whose `ViewNode` is `null`) resolves no scope and renders every non-stdlib definition, +unchanged from the pre-scoping behavior. ##### Error Handling @@ -48,6 +64,8 @@ throw: the strategy returns an empty diagram rather than failing. (`DemaConsulting.Rendering.Abstractions`). - `SysmlWorkspace` and `SysmlDefinitionNode` (Semantic subsystem). - `StdlibFilter` (Rendering Internal subsystem) — standard-library exclusion. +- `ExposeScopeResolver` (Layout Internal subsystem) — `ResolveExposedScope` and + `IsInSubjectScope` supply the shared `expose`-scoping used by `CollectDefinitions`. ##### Callers 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 60594b79..f52a9eeb 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 @@ -22,10 +22,12 @@ content produced by laying out one definition's interior). ###### `BuildLayout(ViewContext context, RenderOptions options)` -Entry point. Selects the root part definition via `FindRoot`, builds the container-definition -index via `BuildDefinitionIndex`, lays out the root's interior via `LayOutInterior`, and assembles -the root container box plus the interior content into the `LayoutTree`. Returns a minimal 200×100 -empty `LayoutTree` when no root or no parts are found. +Entry point. Resolves the view's `expose` scope via `ExposeScopeResolver.ResolveExposedScope`, +selects the root part definition via `FindRoot(workspace, scope)`, builds the container-definition +index via `BuildDefinitionIndex`, lays out the root's interior via `LayOutInterior` (threading +`scope` through every recursive call), and assembles the root container box plus the interior +content into the `LayoutTree`. Returns a minimal 200×100 empty `LayoutTree` when no root or no +parts are found. ###### Recursive nested layout (`LayOutInterior`, `CollectParts`, `BuildDefinitionIndex`) @@ -70,17 +72,39 @@ node by its parent, which is laid out with the **same** flat placement. the known `SEPARATE_CHILDREN` limitation (no true cross-boundary routing); gallery models avoid relying on cross-boundary endpoints. -###### `FindRoot(workspace)` +###### `FindRoot(workspace, scope)` Chooses the non-standard-library `part def` with the most connection usages (breaking ties by the -most part usages) as the definition whose interior to render. +most part usages) as the definition whose interior to render, restricted — when a scope is +resolved — to candidates for which `ExposeScopeResolver.IsRootRelevantToScope` returns `true`; +returns `null` when no candidate is relevant to a non-null scope (an empty canvas results), and +falls back to considering every candidate when `scope` is `null`. ###### `CollectParts(root, theme)` and `ResolveConnections(root, partIndex)` `CollectParts` gathers the root's nested `part` usages, sizing each box from its `name : Type` -label. `ResolveConnections` maps each binary connection's dotted endpoint references to nested-part -indices by matching the first segment against the part names, keeping only distinct, resolvable -pairs. +label, additionally excluding — when a scope is resolved — any part feature whose qualified name +fails `ExposeScopeResolver.IsInSubjectScope`. `ResolveConnections` maps each binary connection's +dotted endpoint references to nested-part indices by matching the first segment against the +(possibly narrowed) part names, keeping only distinct, resolvable pairs; a connection whose +endpoint was excluded by scoping simply fails to resolve and is dropped by this existing +endpoint-lookup logic — no separate edge-side scoping is needed. + +##### Expose Scoping + +Because this strategy renders exactly one selected root's interior, scoping cannot narrow a +workspace-wide collection the way `GridViewLayoutStrategy` and `BrowserViewLayoutStrategy` do; +instead it restricts **which root is selected** and then narrows **which of that root's parts are +shown**. `FindRoot` only considers candidates `ExposeScopeResolver.IsRootRelevantToScope` accepts, +so exposing the current heuristic root itself, an inner part of it, or a definition that itself +contains the heuristic default all correctly select a root, while exposing an unrelated +definition yields no root and thus the minimal empty canvas. `CollectParts` then narrows the +selected root's own part features to those within the resolved scope (via +`ExposeScopeResolver.IsInSubjectScope`), and `ResolveConnections`'s existing "skip connections +whose endpoint did not resolve" behavior transparently drops any connection touching an excluded +part — no new edge-side logic was required. A view with no `expose` statement (including the +synthesized `--auto` view, whose `ViewNode` is `null`) resolves no scope, so `FindRoot` considers +every candidate and `CollectParts` keeps every part, unchanged from the pre-scoping behavior. ###### Placement and routing @@ -110,6 +134,9 @@ Connectors that cannot be routed cleanly are still drawn; this strategy does not - `LayeredPlacement` (Layout Internal subsystem) — placement and routing through `DemaConsulting.Rendering.Layout`. - `StdlibFilter` (Rendering Internal subsystem) — standard-library exclusion. +- `ExposeScopeResolver` (Layout Internal subsystem) — `ResolveExposedScope`, + `IsRootRelevantToScope`, and `IsInSubjectScope` supply the shared `expose`-scoping used by + `BuildLayout`, `FindRoot`, and `CollectParts`. - `SysmlWorkspace`, `SysmlDefinitionNode`, `SysmlFeatureNode`, `SysmlConnectionNode` (Semantic subsystem) — model input. - The `LayoutTree`, `LayoutBox`, `LayoutPort`, and `LayoutLine` data types (`DemaConsulting.Rendering`). diff --git a/docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md index 5979f6c9..79d51ee1 100644 --- a/docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md @@ -20,25 +20,55 @@ record holding the sender and receiver lifeline indices and the message label. O Builds the diagram: -1. **Root selection.** `FindRoot` scans the non-stdlib declarations and chooses the definition that - declares the most `message` connections, so the most message-rich definition drives the view. -2. **Lifeline collection.** `CollectLifelines` walks the root's messages and records the distinct +1. **Scope resolution.** `ExposeScopeResolver.ResolveExposedScope` resolves the view's `expose` + scope once (or `null` when none applies). +2. **Root selection.** `FindRoot` scans the non-stdlib declarations and chooses the definition that + declares the most `message` connections, so the most message-rich definition drives the view — + restricted, when a scope was resolved, to candidates for which + `ExposeScopeResolver.IsRootRelevantToScope` returns `true`. +3. **Lifeline collection.** `CollectLifelines` walks the root's messages and records the distinct participants in first-appearance order, where a participant is the first dot-separated segment of - a message endpoint reference (for example `client` from `client.a`). An index map from name to - column is built alongside. -3. **Message resolution.** `ResolveMessages` maps each message's endpoints to lifeline indices, - preserving declaration order and skipping messages whose endpoints do not resolve. -4. **Arithmetic placement.** Lifeline X is `margin + headerWidth/2 + columnIndex * pitch`, where + a message endpoint reference (for example `client` from `client.a`) — excluding, when a scope was + resolved, any participant whose reconstructed qualified name (`"{root.QualifiedName}::{name}"`, + which matches a directly-nested part feature's own `QualifiedName`) fails + `ExposeScopeResolver.IsInSubjectScope`. An index map from name to column is built alongside. +4. **Message resolution.** `ResolveMessages` maps each message's endpoints to lifeline indices, + preserving declaration order and skipping messages whose endpoints do not resolve — including any + message with an endpoint on a lifeline excluded by scoping, since that lifeline was never added to + the index; no new edge-side logic was required. +5. **Arithmetic placement.** Lifeline X is `margin + headerWidth/2 + columnIndex * pitch`, where `pitch` is computed by `ComputePitch` from the widest label (clamped to a minimum). Message Y is `firstMessageY + messageOrdinal * rowPitch`. Header height and margins derive from the theme. -5. **Node emission.** Each lifeline becomes a `LayoutLifeline`; each message becomes a horizontal +6. **Node emission.** Each lifeline becomes a `LayoutLifeline`; each message becomes a horizontal `LayoutLine` with no source end marker and an open target end marker, carrying the message label as its midpoint label. A message whose sender and receiver are the same lifeline is emitted by `BuildSelfMessage` as a small rectangular self-loop. The open target end marker matches SysML v2 sequence message notation. -When no root is found, or there are no lifelines or messages, a minimal empty `LayoutTree` with no -nodes is returned. +When no root is found (including when scoping excludes every heuristic candidate), or there are no +lifelines or messages (including when scoping excludes every message's remaining endpoint), a +minimal empty `LayoutTree` with no nodes is returned. + +##### Expose Scoping + +Because this strategy renders exactly one selected root's lifelines, scoping restricts **which +root is selected** and then narrows **which of that root's lifelines are shown**, mirroring the +other single-root strategies' approach. `FindRoot` only considers candidates +`ExposeScopeResolver.IsRootRelevantToScope` accepts, so exposing the current heuristic root +itself, an inner lifeline of it, or a definition that itself contains the heuristic default all +correctly select a root, while exposing an unrelated definition yields no root and thus the +minimal empty canvas. `CollectLifelines` then narrows the selected root's lifelines by +reconstructing each candidate participant's qualified name as `"{root.QualifiedName}::{name}"` and +testing it with `ExposeScopeResolver.IsInSubjectScope`; this reconstruction was confirmed reliable +for realistic models — a directly-nested `part` feature under a root part definition, referenced by +a message endpoint's first dotted segment, has exactly this `QualifiedName` — by a dedicated test +(`SequenceView_LifelineQualifiedNameReconstruction_MatchesDeclaredFeature`) mirroring the real +`client-server-sequence.sysml` fixture, so no conservative fallback (restricting only root +selection) was needed. `ResolveMessages`'s existing "skip a message whose endpoint did not resolve" +behavior transparently drops any message touching an excluded lifeline — no new edge-side logic was +required. A view with no `expose` statement (including the synthesized `--auto` view, whose +`ViewNode` is `null`) resolves no scope, so `FindRoot` considers every candidate and +`CollectLifelines` keeps every lifeline, unchanged from the pre-scoping behavior. ##### Error Handling @@ -53,6 +83,9 @@ not throw: the strategy returns an empty diagram rather than failing. (`DemaConsulting.Rendering.Abstractions`). - `SysmlWorkspace`, `SysmlDefinitionNode`, and `SysmlConnectionNode` (Semantic subsystem). - `StdlibFilter` (Rendering Internal subsystem) — standard-library exclusion. +- `ExposeScopeResolver` (Layout Internal subsystem) — `ResolveExposedScope`, + `IsRootRelevantToScope`, and `IsInSubjectScope` supply the shared `expose`-scoping used by + `BuildLayout`, `FindRoot`, and `CollectLifelines`. ##### Callers diff --git a/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md index 0127fd62..da75e3e1 100644 --- a/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md @@ -19,17 +19,23 @@ private records carry intermediate data: `StateItem` (a state with its computed ###### `BuildLayout(ViewContext context, RenderOptions options)` -Entry point. Selects the root state definition via `FindRoot`, collects its states, resolves its -transitions, places the state boxes, adds the initial marker and the transition edges, and -assembles the tree. Returns a minimal 200×100 empty `LayoutTree` when no root or no states are -found. - -###### `FindRoot(workspace)` and `CollectStates(root, theme)` - -`FindRoot` chooses the non-standard-library definition with the most transitions. `CollectStates` -gathers the declared `state` usages first (preserving declaration order so the first declared -state becomes the initial state), then adds any additional state named only by a transition -endpoint, building a name → index lookup. +Entry point. Resolves the view's `expose` scope via `ExposeScopeResolver.ResolveExposedScope`, +selects the root state definition via `FindRoot(workspace, scope)`, collects its states via +`CollectStates(root, theme, scope)`, resolves its transitions, places the state boxes, adds the +initial marker and the transition edges, and assembles the tree. Returns a minimal 200×100 empty +`LayoutTree` when no root or no states are found. + +###### `FindRoot(workspace, scope)` and `CollectStates(root, theme, scope)` + +`FindRoot` chooses the non-standard-library definition with the most transitions, restricted — +when a scope is resolved — to candidates for which `ExposeScopeResolver.IsRootRelevantToScope` +returns `true`. `CollectStates` gathers the declared `state` usages first (preserving declaration +order so the first declared state becomes the initial state), excluding — when a scope is +resolved — any declared state feature whose qualified name fails +`ExposeScopeResolver.IsInSubjectScope`; it then adds any additional state named only by a +transition endpoint **unconditionally** (this second pass has no independent qualified name of its +own to scope against, since it exists solely because a transition names it), building a name → +index lookup. ###### `ResolveTransitions(root, index)` @@ -65,6 +71,25 @@ state transition notation, and labelled with its bracketed guard. A self-transit small loop above its state, also terminated by an open chevron end marker. The method returns the number of transitions whose polyline crosses a non-endpoint state box. +##### Expose Scoping + +Because this strategy renders exactly one selected root's states, scoping restricts **which root +is selected** and then narrows **which of that root's states are shown**, mirroring +`InterconnectionViewLayoutStrategy`'s approach. `FindRoot` only considers candidates +`ExposeScopeResolver.IsRootRelevantToScope` accepts, so exposing the current heuristic root +itself, an inner state of it, or a definition that itself contains the heuristic default all +correctly select a root, while exposing an unrelated definition yields no root and thus the +minimal empty canvas. `CollectStates` then narrows the selected root's own **declared** state +features to those within the resolved scope; however, any declared-but-excluded state that is +still referenced by an in-scope transition is transparently re-added by the unconditional +transition-endpoint pass, since that pass has no independent qualified name to filter against — so +expose-scoping only reliably drops a state that is genuinely isolated (never referenced by any +transition of the selected root). `ResolveTransitions`'s existing name-lookup approach naturally +omits any transition whose endpoint state was never added — no new edge-side logic was required. +A view with no `expose` statement (including the synthesized `--auto` view, whose `ViewNode` is +`null`) resolves no scope, so `FindRoot` considers every candidate and `CollectStates` keeps every +state, unchanged from the pre-scoping behavior. + ##### Error Handling Null `context` or `options` arguments throw `ArgumentNullException`. The absence of an eligible @@ -80,6 +105,9 @@ are surfaced through `LayoutWarnings`. - `LayeredPlacement` (Layout Internal subsystem) — top-to-bottom placement and orthogonal routing through `DemaConsulting.Rendering.Layout`. - `StdlibFilter` (Rendering Internal subsystem) — standard-library exclusion. +- `ExposeScopeResolver` (Layout Internal subsystem) — `ResolveExposedScope`, + `IsRootRelevantToScope`, and `IsInSubjectScope` supply the shared `expose`-scoping used by + `BuildLayout`, `FindRoot`, and `CollectStates`. - `SysmlWorkspace`, `SysmlDefinitionNode`, `SysmlFeatureNode`, `SysmlTransitionNode` (Semantic subsystem) — model input. - `LayoutWarnings` (Layout Internal subsystem) — crossing-warning construction. - The `LayoutTree`, `LayoutBox`, `LayoutBadge`, and `LayoutLine` data types diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml index 81f29a2b..d0344604 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml @@ -77,3 +77,38 @@ sections: tests: - ActionFlowView_BuildLayout_BranchAndJoin - ActionFlowView_BuildLayout_Cycle_IsBroken + + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-ExposeScoping + title: >- + When a view's ViewContext carries one or more resolved Expose edges, + ActionFlowViewLayoutStrategy shall restrict its heuristic root selection to a candidate + relevant to the resolved scope and narrow the selected root's declared actions to those + within that scope, still including any excluded action re-referenced by an in-scope + succession. + justification: | + The Action Flow View renders exactly one selected root's actions, so `expose` scoping + cannot narrow a workspace-wide collection directly; instead it restricts which root is + selected — accepting the current heuristic root itself, an inner action of it, or a + definition containing the heuristic default — and then narrows the selected root's own + declared actions to the resolved scope, while the existing succession-endpoint pass + (which has no independent qualified name to filter against) continues to reintroduce any + action still referenced by an in-scope succession, so only genuinely isolated + out-of-scope actions are dropped. + tests: + - ActionFlowView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot + - ActionFlowView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot + - ActionFlowView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas + - ActionFlowView_BuildLayout_ExposeSingleAction_DropsOutOfScopeAction + - ActionFlowView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot + + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-NoExposeFallback + title: >- + When a view's ViewContext carries no resolved Expose edge — including a null ViewNode + — ActionFlowViewLayoutStrategy shall select its root by the pre-scoping heuristic and + render every action, unchanged from its pre-scoping behavior. + justification: | + Nearly every real-world view declares no `expose` statement, and the synthetic `--auto` + view never carries a ViewNode at all; the pre-scoping heuristic root selection and full + action rendering must remain the default in both cases. + tests: + - ActionFlowView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/browser-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/browser-view-layout-strategy.yaml index ed97b2b0..ed81d96d 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/browser-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/browser-view-layout-strategy.yaml @@ -30,3 +30,32 @@ sections: could be mistaken for missing content. tests: - BrowserAndGrid_BuildLayout_EmptyWorkspace_ReturnMinimalCanvas + + - id: SysML2Tools-Core-Layout-Internal-BrowserViewLayoutStrategy-ExposeScoping + title: >- + When a view's ViewContext carries one or more resolved Expose edges, + BrowserViewLayoutStrategy shall scope the tree to the union of each edge's target + containment subtree, resolved through the target's own type reference when the target + is a usage rather than a definition, promoting an exposed target whose own parent was + filtered out to a forest root. + justification: | + The Browser View has no single-root heuristic to restrict, so it applies the shared + `expose` scope directly as a workspace-wide element filter, giving it the same + observable scoping behavior as every other view kind, while still producing a + well-formed forest when an exposed target's parent falls outside the scope. + tests: + - BrowserView_BuildLayout_ExposedName_UnionsAdditionalSubtree + - BrowserView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees + - BrowserView_BuildLayout_ExposedUsage_ResolvesThroughTypingToDefinitionSubtree + + - id: SysML2Tools-Core-Layout-Internal-BrowserViewLayoutStrategy-NoExposeFallback + title: >- + When a view's ViewContext carries no resolved Expose edge — including a null ViewNode + — BrowserViewLayoutStrategy shall render the full membership forest, unchanged from its + pre-scoping behavior. + justification: | + Nearly every real-world view declares no `expose` statement, and the synthetic `--auto` + view never carries a ViewNode at all; rendering the full membership forest must remain + the default in both cases. + tests: + - BrowserView_BuildLayout_NullViewNode_RendersFullWorkspaceUnchanged diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml new file mode 100644 index 00000000..b34c5648 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml @@ -0,0 +1,80 @@ +--- +# ExposeScopeResolver Unit Requirements +# +# PURPOSE: +# - Define requirements for the ExposeScopeResolver unit +# - ExposeScopeResolver resolves the expose-statement containment-subtree scope shared by every +# layout strategy +# - Requirements describe observable scope-resolution behavior, not internal implementation detail + +sections: + - title: ExposeScopeResolver Unit Requirements + requirements: + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-ResolveExposedTargets + title: >- + ExposeScopeResolver.ResolveExposedScope shall return the qualified names of every + resolved Expose-kind edge on a view as the resolved scope. + justification: | + `expose ;` is the only real content-scoping mechanism a view has in SysML v2. + Collecting every resolved Expose edge's target qualified name is the first step every + layout strategy needs to restrict its diagram to the exposed subject(s). + tests: + - ResolveExposedScope_ExposedDefinition_ReturnsThatQualifiedName + + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-UsageToTypeFallback + title: >- + When a resolved Expose edge's target resolves to a feature usage rather than a + definition, ExposeScopeResolver.ResolveExposedScope shall additionally include that + usage's own resolved Typing-edge target qualified name in the returned scope. + justification: | + A usage's own containment subtree is typically empty — the real content lives under its + type's subtree. Without this fallback, exposing a usage would silently scope the diagram + to nothing; following the usage's own type reference fixes this containment gap. + tests: + - ResolveExposedScope_ExposedUsage_AlsoIncludesResolvedTypeTarget + + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-NoScopeFallback + title: >- + When a view has no resolved Expose-kind edge — including a null ViewNode, a view with + no expose statement, or a view whose every expose entry failed to resolve — + ExposeScopeResolver.ResolveExposedScope shall return null, meaning no scoping applies. + justification: | + Nearly every real-world view declares no `expose` statement, and the synthetic `--auto` + view never carries a ViewNode at all. A null return lets every caller uniformly treat + "no scope" as "render everything", the pre-scoping default. + tests: + - ResolveExposedScope_NullViewNode_ReturnsNull + - ResolveExposedScope_NoResolvedExposeEdges_ReturnsNull + + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-SubjectScopeMembership + title: >- + ExposeScopeResolver.IsInSubjectScope shall return true for a qualified name that + exactly matches a subject or lies within a subject's containment subtree (a + "{subject}::" prefix match), and false otherwise. + justification: | + Every strategy needs a single, consistent rule for "is this element within the exposed + scope" that treats an exposed target's entire containment subtree — not just the target + itself — as included, matching how SysML v2 namespaces nest qualified names. + tests: + - IsInSubjectScope_ExactMatch_ReturnsTrue + - IsInSubjectScope_SubtreeMatch_ReturnsTrue + - IsInSubjectScope_PrefixWithoutSeparator_ReturnsFalse + - IsInSubjectScope_UnrelatedName_ReturnsFalse + + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-RootRelevance + title: >- + ExposeScopeResolver.IsRootRelevantToScope shall return true for a candidate root + qualified name that equals a scope subject, lies within a subject's containment + subtree, or contains a subject within its own containment subtree, and false otherwise. + justification: | + The four single-root layout strategies (Interconnection, State Transition, Action + Flow, Sequence View) each pick one root definition by an internal heuristic before + collecting its children. Restricting that heuristic to a root relevant to the resolved + `expose` scope — in either containment direction — lets exposing an inner element of a + root, or the root itself, correctly select that root, while an unrelated `expose` target + correctly yields no root. + tests: + - IsRootRelevantToScope_CandidateEqualsSubject_ReturnsTrue + - IsRootRelevantToScope_CandidateNestedInSubject_ReturnsTrue + - IsRootRelevantToScope_SubjectNestedInCandidate_ReturnsTrue + - IsRootRelevantToScope_UnrelatedCandidate_ReturnsFalse diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml index b445c0a4..83aae062 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml @@ -30,3 +30,30 @@ sections: that could be mistaken for missing data. tests: - BrowserAndGrid_BuildLayout_EmptyWorkspace_ReturnMinimalCanvas + + - id: SysML2Tools-Core-Layout-Internal-GridViewLayoutStrategy-ExposeScoping + title: >- + When a view's ViewContext carries one or more resolved Expose edges, + GridViewLayoutStrategy shall scope the matrix to the union of each edge's target + containment subtree, resolved through the target's own type reference when the target + is a usage rather than a definition. + justification: | + The Grid View has no single-root heuristic to restrict, so it applies the shared + `expose` scope directly as a workspace-wide definition filter, giving it the same + observable scoping behavior as every other view kind. + tests: + - GridView_BuildLayout_ExposedName_UnionsAdditionalSubtree + - GridView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees + - GridView_BuildLayout_ExposedUsage_ResolvesThroughTypingToDefinitionSubtree + + - id: SysML2Tools-Core-Layout-Internal-GridViewLayoutStrategy-NoExposeFallback + title: >- + When a view's ViewContext carries no resolved Expose edge — including a null ViewNode + — GridViewLayoutStrategy shall render every non-stdlib definition, unchanged from its + pre-scoping behavior. + justification: | + Nearly every real-world view declares no `expose` statement, and the synthetic `--auto` + view never carries a ViewNode at all; rendering the full workspace must remain the + default in both cases. + tests: + - GridView_BuildLayout_NullViewNode_RendersFullWorkspaceUnchanged 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 b4666bc9..046aafb9 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 @@ -118,3 +118,36 @@ sections: still drawing the part. tests: - InterconnectionView_BuildLayout_SelfReferentialType_TreatedAsLeaf + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-ExposeScoping + title: >- + When a view's ViewContext carries one or more resolved Expose edges, + InterconnectionViewLayoutStrategy shall restrict its heuristic root selection to a + candidate relevant to the resolved scope and narrow the selected root's nested parts to + those within that scope, dropping any connection whose endpoint was excluded. + justification: | + The Interconnection View renders exactly one selected root's interior, so `expose` + scoping cannot narrow a workspace-wide collection directly; instead it restricts which + root is selected — accepting the current heuristic root itself, an inner part of it, or + a definition containing the heuristic default — and then narrows the selected root's + own nested parts to the resolved scope, relying on the existing connection endpoint + lookup to drop any connection that references an excluded part. + tests: + - InterconnectionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot + - InterconnectionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot + - InterconnectionView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas + - InterconnectionView_BuildLayout_ExposeSinglePart_NarrowsToThatPart + - InterconnectionView_BuildLayout_ExposeMultipleParts_UnionsBothSubtrees + - InterconnectionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-NoExposeFallback + title: >- + When a view's ViewContext carries no resolved Expose edge — including a null ViewNode + — InterconnectionViewLayoutStrategy shall select its root by the pre-scoping heuristic + and render every nested part, unchanged from its pre-scoping behavior. + justification: | + Nearly every real-world view declares no `expose` statement, and the synthetic `--auto` + view never carries a ViewNode at all; the pre-scoping heuristic root selection and full + interior rendering must remain the default in both cases. + tests: + - InterconnectionView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml index 4390b115..ea9ec72a 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml @@ -50,3 +50,52 @@ sections: property makes rendered message arrows conform to the standard notation. tests: - SequenceView_BuildLayout_MessageArrow_HasOpenArrowhead + + - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-LifelineQualifiedNameReconstruction + title: >- + SequenceViewLayoutStrategy shall reconstruct a candidate lifeline's absolute qualified + name as "{root.QualifiedName}::{lifelineName}" and this reconstruction shall match the + declared feature's own QualifiedName for a directly-nested part feature referenced by a + message endpoint. + justification: | + Filtering lifelines by expose scope requires each lifeline's own qualified name, which + is never stored directly on the lightweight lifeline record; this reconstruction was + confirmed reliable for realistic models — mirroring the real client-server-sequence.sysml + fixture — before being relied upon for scope filtering, avoiding a silently incorrect + filter. + tests: + - SequenceView_LifelineQualifiedNameReconstruction_MatchesDeclaredFeature + + - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-ExposeScoping + title: >- + When a view's ViewContext carries one or more resolved Expose edges, + SequenceViewLayoutStrategy shall restrict its heuristic root selection to a candidate + relevant to the resolved scope and narrow the selected root's lifelines to those within + that scope, dropping any message whose endpoint was excluded. + justification: | + The Sequence View renders exactly one selected root's lifelines, so `expose` scoping + cannot narrow a workspace-wide collection directly; instead it restricts which root is + selected — accepting the current heuristic root itself, an inner lifeline of it, or a + definition containing the heuristic default — and then narrows the selected root's own + lifelines to the resolved scope using the reconstructed qualified name, relying on the + existing message endpoint lookup to drop any message that references an excluded + lifeline. + tests: + - SequenceView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot + - SequenceView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot + - SequenceView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas + - SequenceView_BuildLayout_ExposeSingleLifeline_NarrowsLifelines + - SequenceView_BuildLayout_ExposeBothLifelines_UnionsSubtreesKeepsMessages + - SequenceView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot + + - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-NoExposeFallback + title: >- + When a view's ViewContext carries no resolved Expose edge — including a null ViewNode + — SequenceViewLayoutStrategy shall select its root by the pre-scoping heuristic and + render every lifeline, unchanged from its pre-scoping behavior. + justification: | + Nearly every real-world view declares no `expose` statement, and the synthetic `--auto` + view never carries a ViewNode at all; the pre-scoping heuristic root selection and full + lifeline rendering must remain the default in both cases. + tests: + - SequenceView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml index b47a7562..eee02d98 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml @@ -72,3 +72,38 @@ sections: clear and matches the block-diagram visual style used across the tool. tests: - StateTransitionView_BuildLayout_ForwardChain_FlowsTopToBottomOrthogonally + + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-ExposeScoping + title: >- + When a view's ViewContext carries one or more resolved Expose edges, + StateTransitionViewLayoutStrategy shall restrict its heuristic root selection to a + candidate relevant to the resolved scope and narrow the selected root's declared states + to those within that scope, still including any excluded state re-referenced by an + in-scope transition. + justification: | + The State Transition View renders exactly one selected root's states, so `expose` + scoping cannot narrow a workspace-wide collection directly; instead it restricts which + root is selected — accepting the current heuristic root itself, an inner state of it, or + a definition containing the heuristic default — and then narrows the selected root's own + declared states to the resolved scope, while the existing transition-endpoint pass + (which has no independent qualified name to filter against) continues to reintroduce any + state still referenced by an in-scope transition, so only genuinely isolated + out-of-scope states are dropped. + tests: + - StateTransitionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot + - StateTransitionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot + - StateTransitionView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas + - StateTransitionView_BuildLayout_ExposeSingleState_DropsIsolatedOutOfScopeState + - StateTransitionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot + + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-NoExposeFallback + title: >- + When a view's ViewContext carries no resolved Expose edge — including a null ViewNode + — StateTransitionViewLayoutStrategy shall select its root by the pre-scoping heuristic + and render every state, unchanged from its pre-scoping behavior. + justification: | + Nearly every real-world view declares no `expose` statement, and the synthetic `--auto` + view never carries a ViewNode at all; the pre-scoping heuristic root selection and full + state rendering must remain the default in both cases. + tests: + - StateTransitionView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 1574e21d..8fb31e56 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -136,10 +136,16 @@ entire workspace: - A view with **no** `expose` statement (including the `--auto`-synthesized view) renders the full workspace, exactly as before this scoping behavior was introduced. -Only the General View layout strategy honors `expose` scoping in this release; the other view -kinds (Interconnection, State Transition, Action Flow, Sequence, Grid, Browser) continue to -render their full applicable scope regardless of a view's declared `expose` statements — see -`ROADMAP.md` for the planned follow-up extending scoping to those view kinds. +Every view kind honors `expose` scoping: General, Grid, and Browser Views apply the resolved +scope directly as a filter over their full applicable content. Interconnection, State +Transition, Action Flow, and Sequence Views each render exactly one selected root's contents, so +they instead use the resolved scope in two steps: first, restricting which root the view's own +heuristic selects to one relevant to the scope (the current heuristic root itself, an inner +element of it, or a definition that contains it) — an `expose` statement naming an unrelated +definition yields no root and an empty diagram; second, narrowing that selected root's own +children (parts, states, actions, or lifelines) to those within the resolved scope. A view with +no `expose` statement (including the `--auto`-synthesized view) renders unchanged, exactly as +before this scoping behavior was introduced, for every view kind. 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 diff --git a/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md index 3cb7a41c..465faa12 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md @@ -28,6 +28,19 @@ configuration are required beyond a standard .NET SDK installation. the done marker only to sink actions, and a cyclic flow still emits each succession with an open marker at its true target. - An empty workspace yields a canvas with no nodes. +- A `null` `ViewContext.ViewNode` selects the pre-scoping heuristic root and renders every action, + unchanged from before this feature — the critical `--auto`/no-expose regression guard. +- A view whose resolved `Expose` edge names a definition other than the heuristic root selects + that definition as the root instead. +- A view whose resolved `Expose` edge names an inner action of a non-heuristic-root definition + selects that definition's own root. +- A view whose resolved `Expose` edge names a definition unrelated to any candidate root selects + no root, producing the minimal empty canvas. +- A view whose resolved `Expose` edge names a single action drops a genuinely isolated + out-of-scope action while still rendering any excluded action re-referenced by an in-scope + succession. +- A view whose resolved `Expose` edge names a feature usage (not a definition) still resolves to + the usage's type as the root, via the shared usage-to-type fallback. ##### Test Scenarios @@ -41,3 +54,9 @@ configuration are required beyond a standard .NET SDK installation. | `ActionFlowView_BuildLayout_NoOverlap` | Action boxes do not overlap | | `ActionFlowView_BuildLayout_BranchAndJoin` | Branch/join renders all boxes, markers, and successions | | `ActionFlowView_BuildLayout_Cycle_IsBroken` | Cyclic flow successions keep open markers at true targets | +| `ActionFlowView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged` | Null `ViewNode` renders unchanged | +| `ActionFlowView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot` | Non-heuristic root is selected | +| `ActionFlowView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot` | Inner action selects root | +| `ActionFlowView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas` | Unrelated def, no root | +| `ActionFlowView_BuildLayout_ExposeSingleAction_DropsOutOfScopeAction` | Isolated action dropped; referenced kept | +| `ActionFlowView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | diff --git a/docs/verification/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md index ccfb1d3f..f358f912 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/browser-view-layout-strategy.md @@ -18,6 +18,15 @@ configuration are required beyond a standard .NET SDK installation. all target frameworks. - A nested element's box is indented further than its ancestor's box. - A workspace with no user-defined elements yields an empty diagram. +- A view whose `ViewContext.ViewNode` carries a resolved `Expose` edge scopes the tree to that + target's containment subtree, excluding unrelated sibling elements and producing fewer boxes than + an unscoped (no-`ViewNode`) rendering of the same workspace. +- A view with a `null` `ViewContext.ViewNode` renders the full membership forest, unchanged from + before this feature — the critical `--auto`/no-expose regression guard. +- A view whose resolved `Expose` edge names a feature usage (not a definition) still renders that + usage's type's containment subtree, via the shared usage-to-type fallback. +- A view with an `expose` statement naming two separate definitions unions both their containment + subtrees into the forest. ##### Test Scenarios @@ -25,3 +34,7 @@ configuration are required beyond a standard .NET SDK installation. | --- | --- | | `BrowserView_BuildLayout_NestedElements_AreIndentedByDepth` | Nested element box has larger X than its ancestor box | | `BrowserAndGrid_BuildLayout_EmptyWorkspace_ReturnMinimalCanvas` | Empty workspace yields no nodes | +| `BrowserView_BuildLayout_ExposedName_UnionsAdditionalSubtree` | Resolved `Expose` scopes to fewer boxes | +| `BrowserView_BuildLayout_NullViewNode_RendersFullWorkspaceUnchanged` | Null `ViewNode` renders full forest unchanged | +| `BrowserView_BuildLayout_ExposedUsage_ResolvesThroughTypingToDefinitionSubtree` | Usage resolves via `Typing` | +| `BrowserView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees` | Two `expose` targets union both subtrees | diff --git a/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md b/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md new file mode 100644 index 00000000..e3088b0e --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md @@ -0,0 +1,60 @@ +#### ExposeScopeResolver Verification + +##### Verification Approach + +`ExposeScopeResolver` is verified through direct unit tests in `ExposeScopeResolverTests` that +call `ResolveExposedScope`, `IsInSubjectScope`, and `IsRootRelevantToScope` directly with +synthetic `SysmlWorkspace`/`SysmlViewNode` inputs and assert on the returned scope or boolean +result. No mocking is required; every method is a pure function over its parameters. + +##### Test Environment + +Tests run via `dotnet test` against net8.0, net9.0, and net10.0. No external services, files, or +configuration are required beyond a standard .NET SDK installation. + +##### Acceptance Criteria + +- All `ExposeScopeResolverTests` pass with zero failures across all three target frameworks. +- A `null` `ViewNode` resolves to a `null` scope. +- A `ViewNode` with no resolved `Expose`-kind edges resolves to a `null` scope. +- A resolved `Expose` edge targeting a definition resolves to a scope containing exactly that + definition's qualified name. +- A resolved `Expose` edge targeting a feature usage additionally includes that usage's own + resolved `Typing` edge target qualified name in the scope. +- `IsInSubjectScope` returns `true` for an exact qualified-name match. +- `IsInSubjectScope` returns `true` for a qualified name nested under a subject (a `"{subject}::"` + prefix match). +- `IsInSubjectScope` returns `false` for a qualified name that shares a string prefix with a + subject but without the `"::"` separator (e.g. `Root::AB` vs. subject `Root::A`). +- `IsInSubjectScope` returns `false` for an unrelated qualified name. +- `IsRootRelevantToScope` returns `true` when the candidate equals a subject. +- `IsRootRelevantToScope` returns `true` when the candidate is nested within a subject. +- `IsRootRelevantToScope` returns `true` when a subject is nested within the candidate. +- `IsRootRelevantToScope` returns `false` for an unrelated candidate. + +##### Test Scenarios + +- `ResolveExposedScope_NullViewNode_ReturnsNull`: + Null `ViewNode` resolves to `null` scope +- `ResolveExposedScope_NoResolvedExposeEdges_ReturnsNull`: + No resolved `Expose` edges resolves to `null` scope +- `ResolveExposedScope_ExposedDefinition_ReturnsThatQualifiedName`: + Exposed definition resolves to a scope of exactly that qualified name +- `ResolveExposedScope_ExposedUsage_AlsoIncludesResolvedTypeTarget`: + Exposed usage resolves to a scope including both the usage and its resolved type +- `IsInSubjectScope_ExactMatch_ReturnsTrue`: + Exact qualified-name match is in scope +- `IsInSubjectScope_SubtreeMatch_ReturnsTrue`: + Nested qualified name is in scope +- `IsInSubjectScope_PrefixWithoutSeparator_ReturnsFalse`: + String-prefix-only match (no `::`) is not in scope +- `IsInSubjectScope_UnrelatedName_ReturnsFalse`: + Unrelated qualified name is not in scope +- `IsRootRelevantToScope_CandidateEqualsSubject_ReturnsTrue`: + Candidate equal to a subject is relevant +- `IsRootRelevantToScope_CandidateNestedInSubject_ReturnsTrue`: + Candidate nested within a subject is relevant +- `IsRootRelevantToScope_SubjectNestedInCandidate_ReturnsTrue`: + Subject nested within the candidate is relevant +- `IsRootRelevantToScope_UnrelatedCandidate_ReturnsFalse`: + Unrelated candidate is not relevant diff --git a/docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md index 4bafbfb6..588771e6 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md @@ -19,6 +19,15 @@ configuration are required beyond a standard .NET SDK installation. - Definitions with a specialization relationship yield a grid with a header row and exactly one mark at the specializing intersection. - A workspace with no user-defined definitions yields an empty diagram. +- A view whose `ViewContext.ViewNode` carries a resolved `Expose` edge scopes the matrix to that + target's containment subtree, excluding unrelated sibling definitions and producing fewer rows + than an unscoped (no-`ViewNode`) rendering of the same workspace. +- A view with a `null` `ViewContext.ViewNode` renders every non-stdlib definition, unchanged from + before this feature — the critical `--auto`/no-expose regression guard. +- A view whose resolved `Expose` edge names a feature usage (not a definition) still renders that + usage's type's containment subtree, via the shared usage-to-type fallback. +- A view with an `expose` statement naming two separate definitions unions both their containment + subtrees into the matrix. ##### Test Scenarios @@ -26,3 +35,7 @@ configuration are required beyond a standard .NET SDK installation. | --- | --- | | `GridView_BuildLayout_Specialization_ProducesMarkedMatrix` | Grid has a header row and one specialization mark | | `BrowserAndGrid_BuildLayout_EmptyWorkspace_ReturnMinimalCanvas` | Empty workspace yields no nodes | +| `GridView_BuildLayout_ExposedName_UnionsAdditionalSubtree` | Resolved `Expose` scopes the matrix to fewer rows | +| `GridView_BuildLayout_NullViewNode_RendersFullWorkspaceUnchanged` | Null `ViewNode` renders all defs unchanged | +| `GridView_BuildLayout_ExposedUsage_ResolvesThroughTypingToDefinitionSubtree` | Usage resolves via `Typing` | +| `GridView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees` | Two `expose` targets union both subtrees | 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 8ff67b2b..c0f639c2 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 @@ -29,6 +29,20 @@ configuration are required beyond a standard .NET SDK installation. - Nested children are emitted at absolute coordinates offset from the container origin. - A flat model (no nested internal structure) produces only leaf part boxes with no children. - A self-referential part type terminates (cycle guard) and is rendered as a leaf box. +- A `null` `ViewContext.ViewNode` selects the pre-scoping heuristic root and renders every nested + part, unchanged from before this feature — the critical `--auto`/no-expose regression guard. +- A view whose resolved `Expose` edge names a definition other than the heuristic root selects + that definition as the root instead. +- A view whose resolved `Expose` edge names an inner part of a non-heuristic-root definition + selects that definition's own root, narrowing its parts and dropping the connection to the + excluded part. +- A view whose resolved `Expose` edge names a definition unrelated to any candidate root selects + no root, producing the minimal empty canvas. +- A view whose resolved `Expose` edge names a single part narrows the container to that part. +- A view with an `expose` statement naming two separate parts unions both their containment + subtrees, keeping the connection between them. +- A view whose resolved `Expose` edge names a feature usage (not a definition) still resolves to + the usage's type as the root, via the shared usage-to-type fallback. ##### Test Scenarios @@ -42,3 +56,10 @@ configuration are required beyond a standard .NET SDK installation. | `InterconnectionView_BuildLayout_NestedChildren_RenderedAtAbsoluteCoordinates` | Children at absolute coordinates | | `InterconnectionView_BuildLayout_NoNesting_ProducesFlatLeafBoxes` | Flat model yields only leaf boxes (no children) | | `InterconnectionView_BuildLayout_SelfReferentialType_TreatedAsLeaf` | Self-referential type renders as leaf | +| `InterconnectionView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged` | Null `ViewNode` renders unchanged | +| `InterconnectionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot` | Non-heuristic root is selected | +| `InterconnectionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot` | Inner child selects root | +| `InterconnectionView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas` | Unrelated def | +| `InterconnectionView_BuildLayout_ExposeSinglePart_NarrowsToThatPart` | Single exposed part narrows the container | +| `InterconnectionView_BuildLayout_ExposeMultipleParts_UnionsBothSubtrees` | Two exposed parts union both subtrees | +| `InterconnectionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | diff --git a/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md index 01699742..3345b510 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md @@ -19,6 +19,25 @@ configuration are required beyond a standard .NET SDK installation. top-to-bottom by declaration order. - A message between two lifelines is a horizontal line with an open end marker at the receiver. - A workspace with no messages yields an empty diagram. +- A directly-nested `part` feature under a root part definition, referenced by a message + endpoint's first dotted segment, has a `QualifiedName` matching the reconstructed + `"{root.QualifiedName}::{lifelineName}"` form — confirming Assumption 4 of the expose-scoping + plan holds for realistic models before it is relied upon for lifeline-level scope filtering. +- A `null` `ViewContext.ViewNode` selects the pre-scoping heuristic root and renders every + lifeline, unchanged from before this feature — the critical `--auto`/no-expose regression guard. +- A view whose resolved `Expose` edge names a definition other than the heuristic root selects + that definition as the root instead. +- A view whose resolved `Expose` edge names an inner lifeline of a non-heuristic-root definition + selects that definition's own root. +- A view whose resolved `Expose` edge names a definition unrelated to any candidate root selects + no root, producing the minimal empty canvas. +- A view whose resolved `Expose` edge names a single lifeline narrows the diagram to that lifeline + (plus any lifeline still reachable via a surviving message), dropping messages whose other + endpoint was excluded. +- A view with an `expose` statement naming two separate lifelines unions both, keeping every + message between them. +- A view whose resolved `Expose` edge names a feature usage (not a definition) still resolves to + the usage's type as the root, via the shared usage-to-type fallback. ##### Test Scenarios @@ -28,3 +47,11 @@ configuration are required beyond a standard .NET SDK installation. | `SequenceView_BuildLayout_Message_IsHorizontalBetweenLifelines` | Horizontal line, open end marker at receiver | | `SequenceView_BuildLayout_NoMessages_ReturnsMinimalCanvas` | Workspace with no messages yields no nodes | | `SequenceView_BuildLayout_MessageArrow_HasOpenArrowhead` | Open end marker at receiver end | +| `SequenceView_LifelineQualifiedNameReconstruction_MatchesDeclaredFeature` | Reconstructed name matches feature | +| `SequenceView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged` | Null `ViewNode` renders unchanged | +| `SequenceView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot` | Non-heuristic root is selected | +| `SequenceView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot` | Inner lifeline selects root | +| `SequenceView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas` | Unrelated def | +| `SequenceView_BuildLayout_ExposeSingleLifeline_NarrowsLifelines` | Single exposed lifeline narrows the diagram | +| `SequenceView_BuildLayout_ExposeBothLifelines_UnionsSubtreesKeepsMessages` | Two lifelines union, keeping messages | +| `SequenceView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | diff --git a/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md index 9f557c3b..cde66d96 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md @@ -26,6 +26,19 @@ configuration are required beyond a standard .NET SDK installation. - Each transition edge carries an open chevron end marker at the target state. - A forward chain of transitions flows top-to-bottom with orthogonal transition polylines. - An empty workspace yields a canvas with no nodes. +- A `null` `ViewContext.ViewNode` selects the pre-scoping heuristic root and renders every state, + unchanged from before this feature — the critical `--auto`/no-expose regression guard. +- A view whose resolved `Expose` edge names a definition other than the heuristic root selects + that definition as the root instead. +- A view whose resolved `Expose` edge names an inner state of a non-heuristic-root definition + selects that definition's own root. +- A view whose resolved `Expose` edge names a definition unrelated to any candidate root selects + no root, producing the minimal empty canvas. +- A view whose resolved `Expose` edge names a single state drops a genuinely isolated + out-of-scope state while still rendering any excluded state re-referenced by an in-scope + transition. +- A view whose resolved `Expose` edge names a feature usage (not a definition) still resolves to + the usage's type as the root, via the shared usage-to-type fallback. ##### Test Scenarios @@ -37,3 +50,9 @@ configuration are required beyond a standard .NET SDK installation. | `StateTransitionView_BuildLayout_InAndOutOnSameEdge_UseDistinctAnchors` | In/out transitions use distinct anchors | | `StateTransitionView_BuildLayout_TransitionEdge_HasOpenArrowhead` | Open chevron end marker at target state | | `StateTransitionView_BuildLayout_ForwardChain_FlowsTopToBottomOrthogonally` | Top-to-bottom orthogonal flow | +| `StateTransitionView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged` | Null `ViewNode` renders unchanged | +| `StateTransitionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot` | Non-heuristic root is selected | +| `StateTransitionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot` | Inner state selects its root | +| `StateTransitionView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas` | Unrelated def | +| `StateTransitionView_BuildLayout_ExposeSingleState_DropsIsolatedOutOfScopeState` | Isolated state dropped | +| `StateTransitionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs index 78fc7ba3..fd42ee04 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs @@ -53,13 +53,15 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var theme = options.Theme; - var root = FindRoot(context.Workspace); + var scope = ExposeScopeResolver.ResolveExposedScope(context.Workspace, context.ViewNode); + + var root = FindRoot(context.Workspace, scope); if (root is null) { return new LayoutTree(200.0, 100.0, []); } - var (actions, index) = CollectActions(root, theme); + var (actions, index) = CollectActions(root, theme, scope); if (actions.Count == 0) { return new LayoutTree(200.0, 100.0, []); @@ -113,8 +115,15 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) return new LayoutTree(maxX + margin, maxY + margin, nodes) { Warnings = warnings }; } - /// Finds the definition with the most successions to use as the diagram root. - private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace) + /// + /// Finds the definition with the most successions to use as the diagram root. When + /// is non-null (the view's resolved expose containment-subtree + /// scope), candidates are first restricted to those relevant to the scope via + /// before the succession/action-count + /// tie-break applies; when no candidate is scope-relevant, no root is chosen (an empty canvas + /// results). + /// + private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace, IReadOnlyList? scope) { SysmlDefinitionNode? best = null; var bestScore = -1; @@ -131,6 +140,11 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) continue; } + if (scope is not null && !ExposeScopeResolver.IsRootRelevantToScope(qualifiedName, scope)) + { + continue; + } + var successions = def.Children.OfType().Count(); var actions = def.Children.OfType().Count(f => f.FeatureKeyword == "action"); var score = (successions * 100) + actions; @@ -144,10 +158,18 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) return best; } - /// Collects the action usages of the root definition and builds a name → index lookup. + /// + /// Collects the action usages of the root definition and builds a name → index lookup. When + /// is non-null, an action-keyword feature whose own + /// QualifiedName fails is skipped; + /// actions synthesized only from succession endpoints have no independent qualified name and + /// are always added, since they exist only because a succession endpoint that already named + /// them exists on the (already scope-selected) root. + /// private static (IReadOnlyList Actions, Dictionary Index) CollectActions( SysmlDefinitionNode root, - Theme theme) + Theme theme, + IReadOnlyList? scope) { var actions = new List(); var index = new Dictionary(StringComparer.Ordinal); @@ -166,10 +188,18 @@ void Add(string name) foreach (var feature in root.Children.OfType()) { - if (feature.FeatureKeyword == "action" && feature.Name is not null) + if (feature.FeatureKeyword != "action" || feature.Name is null) { - Add(feature.Name); + continue; } + + if (scope is not null && feature.QualifiedName is { Length: > 0 } fqn && + !ExposeScopeResolver.IsInSubjectScope(fqn, scope)) + { + continue; + } + + Add(feature.Name); } foreach (var succession in root.Children.OfType()) diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs index b9d75755..e6663297 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/BrowserViewLayoutStrategy.cs @@ -39,7 +39,7 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var theme = options.Theme; - var roots = BuildForest(context.Workspace); + var roots = BuildForest(context.Workspace, ExposeScopeResolver.ResolveExposedScope(context.Workspace, context.ViewNode)); if (roots.Count == 0) { return new LayoutTree(200.0, 100.0, []); @@ -62,9 +62,15 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) /// /// Builds the membership forest from the non-stdlib declarations using their qualified-name - /// nesting (parent = prefix before the last ::). + /// nesting (parent = prefix before the last ::), restricted to + /// when non-null (the view's resolved expose containment subtrees). Filtering the + /// deterministic names list to only scope-matching qualified names is sufficient: the + /// existing parent-lookup already promotes an element to a forest root whenever its parent is + /// absent from byName, so an exposed target (or its nearest in-scope ancestor) becomes a + /// forest root automatically, yielding "only the subtree(s) rooted at exposed targets" with no + /// additional tree-building logic. /// - private static IReadOnlyList BuildForest(SysmlWorkspace workspace) + private static IReadOnlyList BuildForest(SysmlWorkspace workspace, IReadOnlyList? scope) { var byName = new Dictionary(StringComparer.Ordinal); var roots = new List(); @@ -72,6 +78,7 @@ private static IReadOnlyList BuildForest(SysmlWorkspace workspace) // Deterministic order: sort qualified names so parents precede children. var names = workspace.Declarations.Keys .Where(qn => !StdlibFilter.IsStdlibElement(qn, workspace.StdlibNames)) + .Where(qn => scope is null || ExposeScopeResolver.IsInSubjectScope(qn, scope)) .OrderBy(qn => qn, StringComparer.Ordinal) .ToList(); diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs new file mode 100644 index 00000000..86294274 --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs @@ -0,0 +1,109 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Rendering; +using DemaConsulting.SysML2Tools.Rendering.Internal; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Model; + +namespace DemaConsulting.SysML2Tools.Layout.Internal; + +/// +/// Shared helper resolving the qualified-name containment-subtree scope a view's expose +/// statements restrict a diagram to, used by every so each view kind +/// honors expose scoping identically. +/// +internal static class ExposeScopeResolver +{ + /// + /// Resolves the qualified-name containment-subtree scope a view's expose statements + /// restrict the diagram to, or when the view has no resolved + /// edge — meaning every non-stdlib element is included, + /// unchanged from the pre-scoping behavior. This covers every "no scoping" case uniformly: a + /// null (the --auto synthetic view, which never carries + /// expose/render/filter data), a view with no expose statement, and a view whose every + /// expose entry failed to resolve. RenderTargetName (a rendering-style/format + /// selector, not content) and FilterExpressionText never affect this decision. + /// + /// + /// When an exposed target resolves to a (a usage, e.g. + /// part myVehicle : Vehicle;) rather than a , the + /// usage's own containment subtree is typically empty — the real content lives under its + /// type's subtree. To avoid silently scoping to nothing, this also resolves the usage's own + /// edge (if any) and adds that type's qualified name to the + /// scope as well, so both the usage and its type's subtree are included. + /// + /// The workspace, used to look up each exposed target's declaration. + /// The view's AST node, or null for the synthetic --auto view. + /// + /// The list of subject qualified names (each exposed name, plus the resolved type of any + /// exposed name that names a usage) whose containment subtrees are in scope, or null when no + /// scoping applies. + /// + public static IReadOnlyList? ResolveExposedScope(SysmlWorkspace workspace, SysmlViewNode? viewNode) + { + var exposedTargets = viewNode?.ResolvedEdges + .Where(edge => edge.Kind == SysmlEdgeKind.Expose) + .Select(edge => edge.TargetQualifiedName) + .ToList(); + if (exposedTargets is not { Count: > 0 }) + { + return null; + } + + var subjects = new List(); + foreach (var target in exposedTargets) + { + subjects.Add(target); + + if (workspace.Declarations.TryGetValue(target, out var declaration) && + declaration is SysmlFeatureNode { } feature) + { + var typeTarget = feature.ResolvedEdges + .FirstOrDefault(edge => edge.Kind == SysmlEdgeKind.Typing) + ?.TargetQualifiedName; + if (typeTarget is not null) + { + subjects.Add(typeTarget); + } + } + } + + return subjects; + } + + /// + /// Returns when is one of + /// or lies within one of their containment subtrees (a + /// "{subject}::" prefix match), reusing the same qualified-name-prefix idiom + /// already uses for + /// stdlib-prefix matching. + /// + public static bool IsInSubjectScope(string qualifiedName, IReadOnlyList subjects) => + subjects.Any(subject => + qualifiedName == subject || qualifiedName.StartsWith(subject + "::", StringComparison.Ordinal)); + + /// + /// Returns when (a candidate + /// single-root diagram root, e.g. the definition a , + /// , , 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, the candidate lies within an exposed + /// subject's containment subtree, or an exposed subject lies within the candidate's own + /// containment subtree (the common "expose an inner state/action/part/lifeline of the root" + /// case). + /// + /// The candidate root definition's qualified name. + /// The resolved expose scope subject qualified names. + /// + /// when the candidate root is relevant to the resolved scope; + /// otherwise . + /// + public static bool IsRootRelevantToScope(string candidateQualifiedName, IReadOnlyList subjects) => + subjects.Any(subject => + candidateQualifiedName == subject || + candidateQualifiedName.StartsWith(subject + "::", StringComparison.Ordinal) || + subject.StartsWith(candidateQualifiedName + "::", StringComparison.Ordinal)); +} diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs index 1e88b7c6..b9af9456 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs @@ -114,7 +114,7 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) // view whose every `expose` entry failed to resolve. A null scope means "render // everything", byte-identical to the pre-scoping behavior. RenderTargetName and // FilterExpressionText never affect this decision. - var scope = ResolveExposedScope(context.Workspace, context.ViewNode); + var scope = ExposeScopeResolver.ResolveExposedScope(context.Workspace, context.ViewNode); // Collect all user-defined definitions, sized for rendering, restricted to the resolved // scope when one applies. @@ -155,74 +155,6 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) return warnings.Count == 0 ? placed : placed with { Warnings = warnings }; } - /// - /// Resolves the qualified-name containment-subtree scope a view's expose statements - /// restrict the diagram to, or when the view has no resolved - /// edge — meaning every non-stdlib definition is included, - /// unchanged from the pre-scoping behavior. This covers every "no scoping" case uniformly: a - /// null (the --auto synthetic view, which never carries - /// expose/render/filter data), a view with no expose statement, and a view whose every - /// expose entry failed to resolve. RenderTargetName (a rendering-style/format - /// selector, not content) and FilterExpressionText never affect this decision. - /// - /// - /// When an exposed target resolves to a (a usage, e.g. - /// part myVehicle : Vehicle;) rather than a , the - /// usage's own containment subtree is typically empty — the real content lives under its - /// type's subtree. To avoid silently scoping to nothing, this also resolves the usage's own - /// edge (if any) and adds that type's qualified name to the - /// scope as well, so both the usage and its type's subtree are included. - /// - /// The workspace, used to look up each exposed target's declaration. - /// The view's AST node, or null for the synthetic --auto view. - /// - /// The list of subject qualified names (each exposed name, plus the resolved type of any - /// exposed name that names a usage) whose containment subtrees are in scope, or null when no - /// scoping applies. - /// - private static IReadOnlyList? ResolveExposedScope(SysmlWorkspace workspace, SysmlViewNode? viewNode) - { - var exposedTargets = viewNode?.ResolvedEdges - .Where(edge => edge.Kind == SysmlEdgeKind.Expose) - .Select(edge => edge.TargetQualifiedName) - .ToList(); - if (exposedTargets is not { Count: > 0 }) - { - return null; - } - - var subjects = new List(); - foreach (var target in exposedTargets) - { - subjects.Add(target); - - if (workspace.Declarations.TryGetValue(target, out var declaration) && - declaration is SysmlFeatureNode { } feature) - { - var typeTarget = feature.ResolvedEdges - .FirstOrDefault(edge => edge.Kind == SysmlEdgeKind.Typing) - ?.TargetQualifiedName; - if (typeTarget is not null) - { - subjects.Add(typeTarget); - } - } - } - - return subjects; - } - - /// - /// Returns when is one of - /// or lies within one of their containment subtrees (a - /// "{subject}::" prefix match), reusing the same qualified-name-prefix idiom - /// already uses for - /// stdlib-prefix matching. - /// - private static bool IsInSubjectScope(string qualifiedName, IReadOnlyList subjects) => - subjects.Any(subject => - qualifiedName == subject || qualifiedName.StartsWith(subject + "::", StringComparison.Ordinal)); - /// /// Collects every user-defined from the workspace and computes /// each box's intrinsic size from its keyword and name, restricted to @@ -247,7 +179,7 @@ private static IReadOnlyList CollectDefinitions( continue; } - if (scope is not null && !IsInSubjectScope(qualifiedName, scope)) + if (scope is not null && !ExposeScopeResolver.IsInSubjectScope(qualifiedName, scope)) { continue; } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs index d2bf73ed..d92c2464 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs @@ -36,7 +36,7 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var theme = options.Theme; - var defs = CollectDefinitions(context.Workspace); + var defs = CollectDefinitions(context.Workspace, ExposeScopeResolver.ResolveExposedScope(context.Workspace, context.ViewNode)); if (defs.Count == 0) { return new LayoutTree(200.0, 100.0, []); @@ -88,8 +88,12 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) /// A user-defined definition with its supertype references. private sealed record DefRow(string Name, IReadOnlyList SupertypeNames); - /// Collects the non-stdlib definitions of the workspace in deterministic order. - private static IReadOnlyList CollectDefinitions(SysmlWorkspace workspace) + /// + /// Collects the non-stdlib definitions of the workspace in deterministic order, restricted to + /// when non-null (the view's resolved expose containment + /// subtrees). + /// + private static IReadOnlyList CollectDefinitions(SysmlWorkspace workspace, IReadOnlyList? scope) { var result = new List(); foreach (var qn in workspace.Declarations.Keys.OrderBy(k => k, StringComparer.Ordinal)) @@ -99,6 +103,11 @@ private static IReadOnlyList CollectDefinitions(SysmlWorkspace workspace continue; } + if (scope is not null && !ExposeScopeResolver.IsInSubjectScope(qn, scope)) + { + continue; + } + if (workspace.Declarations[qn] is SysmlDefinitionNode def) { result.Add(new DefRow(def.Name ?? qn, def.SupertypeNames)); diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs index cd2caebf..ee645424 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs @@ -88,8 +88,10 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var theme = options.Theme; + var scope = ExposeScopeResolver.ResolveExposedScope(context.Workspace, context.ViewNode); + // Choose the part definition whose internals to show. - var root = FindRoot(context.Workspace); + var root = FindRoot(context.Workspace, scope); if (root is null) { return new LayoutTree(200.0, 100.0, []); @@ -112,7 +114,7 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) visited.Add(root.QualifiedName); } - var interior = LayOutInterior(root, theme, depth: 0, defsByName, visited); + var interior = LayOutInterior(root, theme, depth: 0, defsByName, visited, scope); var nodes = new List(interior.Content.Count + 1) { @@ -146,15 +148,19 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) /// Nesting depth of this definition's container box (0 for the root). /// Container-definition index keyed by qualified and simple name. /// Qualified names already on the recursion path, guarding against cycles. + /// + /// The view's resolved expose containment-subtree scope, or null when no scoping applies. + /// /// The laid-out interior size and content. private static InteriorLayout LayOutInterior( SysmlDefinitionNode def, Theme theme, int depth, IReadOnlyDictionary defsByName, - ISet visited) + ISet visited, + IReadOnlyList? scope) { - var parts = CollectParts(def, theme, depth, defsByName, visited); + var parts = CollectParts(def, theme, depth, defsByName, visited, scope); var partIndex = BuildPartIndex(parts); var pairs = ResolveConnections(def, partIndex); @@ -243,9 +249,16 @@ private static InteriorLayout LayOutInterior( /// /// Finds the part definition whose interior to render: the non-stdlib part def - /// with the most connections, falling back to the one with the most part usages. + /// with the most connections, falling back to the one with the most part usages. When + /// is non-null (the view's resolved expose containment-subtree + /// scope), candidates are first restricted to those relevant to the scope via + /// — the candidate itself is an exposed + /// subject, lies within an exposed subject's subtree, or an exposed subject lies within the + /// candidate's own subtree — before the same connections/parts tie-break applies; when no + /// candidate is scope-relevant, no root is chosen (an empty canvas results, matching the + /// existing null-root path). /// - private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace) + private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace, IReadOnlyList? scope) { SysmlDefinitionNode? best = null; var bestConnections = -1; @@ -263,6 +276,11 @@ private static InteriorLayout LayOutInterior( continue; } + if (scope is not null && !ExposeScopeResolver.IsRootRelevantToScope(qualifiedName, scope)) + { + continue; + } + var connections = def.Children.OfType().Count(); var partCount = def.Children.OfType().Count(f => f.FeatureKeyword == "part"); @@ -281,14 +299,19 @@ private static InteriorLayout LayOutInterior( /// Collects the nested part usages of a definition, sized for rendering. A part whose type /// resolves to a container definition (a non-stdlib part def with its own internal parts, /// not already on the recursion path) is laid out recursively and sized to fit its interior; - /// every other part is sized intrinsically as a leaf. + /// every other part is sized intrinsically as a leaf. When is non-null, + /// a part feature whose own QualifiedName fails + /// is skipped; the same absolute + /// list is passed unchanged into recursive container calls, since + /// subject qualified names are absolute and need no re-resolution at deeper nesting levels. /// private static IReadOnlyList CollectParts( SysmlDefinitionNode root, Theme theme, int depth, IReadOnlyDictionary defsByName, - ISet visited) + ISet visited, + IReadOnlyList? scope) { var result = new List(); foreach (var feature in root.Children.OfType()) @@ -298,13 +321,19 @@ private static IReadOnlyList CollectParts( continue; } + if (scope is not null && feature.QualifiedName is { Length: > 0 } fqn && + !ExposeScopeResolver.IsInSubjectScope(fqn, scope)) + { + continue; + } + 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. var childVisited = new HashSet(visited, StringComparer.Ordinal) { childDef.QualifiedName! }; - var inner = LayOutInterior(childDef, theme, depth + 1, defsByName, childVisited); + var inner = LayOutInterior(childDef, theme, depth + 1, defsByName, childVisited, scope); result.Add(new PartItem(name, "part", feature.FeatureTyping, inner.Width, inner.Height, inner.Content)); } else diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs index 69532f89..d41c5050 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs @@ -40,13 +40,15 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var theme = options.Theme; - var root = FindRoot(context.Workspace); + var scope = ExposeScopeResolver.ResolveExposedScope(context.Workspace, context.ViewNode); + + var root = FindRoot(context.Workspace, scope); if (root is null) { return new LayoutTree(200.0, 100.0, []); } - var (lifelines, index) = CollectLifelines(root); + var (lifelines, index) = CollectLifelines(root, scope); var messages = ResolveMessages(root, index); if (lifelines.Count == 0 || messages.Count == 0) { @@ -106,8 +108,14 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) return new LayoutTree(width, height, nodes); } - /// Finds the definition with the most messages to use as the diagram root. - private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace) + /// + /// Finds the definition with the most messages to use as the diagram root. When + /// is non-null (the view's resolved expose containment-subtree + /// scope), candidates are first restricted to those relevant to the scope via + /// before the message-count tie-break + /// applies; when no candidate is scope-relevant, no root is chosen (an empty canvas results). + /// + private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace, IReadOnlyList? scope) { SysmlDefinitionNode? best = null; var bestMessages = 0; @@ -124,6 +132,11 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) continue; } + if (scope is not null && !ExposeScopeResolver.IsRootRelevantToScope(qualifiedName, scope)) + { + continue; + } + var messages = def.Children.OfType().Count(c => c.ConnectionKeyword == "message"); if (messages > bestMessages) { @@ -137,9 +150,19 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) /// /// Collects the lifelines participating in the root's messages — the distinct first segments of - /// the message from/to references — in first-appearance order. + /// the message from/to references — in first-appearance order. When is + /// non-null, a lifeline is skipped when its reconstructed absolute qualified name + /// ("{root.QualifiedName}::{lifelineName}" — a message-endpoint lifeline name is the first + /// dotted segment of an endpoint reference, which names a feature declared directly under + /// , confirmed against real declared features in + /// client-server-sequence.sysml) fails . + /// When has no QualifiedName (defensive; every workspace + /// declaration carries one), the reconstruction is skipped and lifeline scoping does not apply, + /// so the strategy never filters based on an unreliable name. /// - private static (IReadOnlyList Lifelines, Dictionary Index) CollectLifelines(SysmlDefinitionNode root) + private static (IReadOnlyList Lifelines, Dictionary Index) CollectLifelines( + SysmlDefinitionNode root, + IReadOnlyList? scope) { var lifelines = new List(); var index = new Dictionary(StringComparer.Ordinal); @@ -152,6 +175,15 @@ void Add(string? reference) return; } + if (scope is not null && root.QualifiedName is { Length: > 0 } rootQualified) + { + var reconstructed = $"{rootQualified}::{name}"; + if (!ExposeScopeResolver.IsInSubjectScope(reconstructed, scope)) + { + return; + } + } + index[name] = lifelines.Count; lifelines.Add(name); } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs index 20ec8b1d..806b922c 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs @@ -58,13 +58,15 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var theme = options.Theme; - var root = FindRoot(context.Workspace); + var scope = ExposeScopeResolver.ResolveExposedScope(context.Workspace, context.ViewNode); + + var root = FindRoot(context.Workspace, scope); if (root is null) { return new LayoutTree(200.0, 100.0, []); } - var (states, index) = CollectStates(root, theme); + var (states, index) = CollectStates(root, theme, scope); if (states.Count == 0) { return new LayoutTree(200.0, 100.0, []); @@ -202,7 +204,15 @@ private static double LabelRightExtent(IReadOnlyList nodes, Theme th return maxX; } - private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace) + /// + /// Finds the state-machine definition whose states/transitions to render: the non-stdlib + /// definition with the most children. When + /// is non-null (the view's resolved expose containment-subtree + /// scope), candidates are first restricted to those relevant to the scope via + /// before the transition-count tie-break + /// applies; when no candidate is scope-relevant, no root is chosen (an empty canvas results). + /// + private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace, IReadOnlyList? scope) { SysmlDefinitionNode? best = null; var bestTransitions = -1; @@ -219,6 +229,11 @@ private static double LabelRightExtent(IReadOnlyList nodes, Theme th continue; } + if (scope is not null && !ExposeScopeResolver.IsRootRelevantToScope(qualifiedName, scope)) + { + continue; + } + var transitions = def.Children.OfType().Count(); if (transitions > bestTransitions) { @@ -232,11 +247,17 @@ private static double LabelRightExtent(IReadOnlyList nodes, Theme th /// /// Collects the states of the root definition — both declared state usages and any state names - /// referenced only by transitions — and builds a name → index lookup. + /// referenced only by transitions — and builds a name → index lookup. When + /// is non-null, a declared state-keyword feature whose own + /// QualifiedName fails is skipped; + /// states synthesized only from transition endpoints have no independent qualified name and are + /// always added, since they exist only because a transition endpoint that already named them + /// exists on the (already scope-selected) root. /// private static (IReadOnlyList States, Dictionary Index) CollectStates( SysmlDefinitionNode root, - Theme theme) + Theme theme, + IReadOnlyList? scope) { var states = new List(); var index = new Dictionary(StringComparer.Ordinal); @@ -256,10 +277,18 @@ void Add(string name) // Declared state usages first (preserves declaration order for the initial-state choice). foreach (var feature in root.Children.OfType()) { - if (feature.FeatureKeyword == "state" && feature.Name is not null) + if (feature.FeatureKeyword != "state" || feature.Name is null) { - Add(feature.Name); + continue; + } + + if (scope is not null && feature.QualifiedName is { Length: > 0 } fqn && + !ExposeScopeResolver.IsInSubjectScope(fqn, scope)) + { + continue; } + + Add(feature.Name); } // Any additional states referenced only by transition endpoints. diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs index 41ceb6f4..56cdf169 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs @@ -375,4 +375,220 @@ static bool EndsNear(LayoutLine line, LayoutBox box) => /// Tolerance for matching a back-edge chevron to its target box face. private const double MarkerBandTolerance = 60.0; + + /// + /// Builds a workspace with two candidate roots: M::ProcessA (two successions, three + /// actions — the heuristic's default pick), M::ProcessB (one succession, two + /// actions), and an unrelated sibling definition M::Unrelated with no + /// actions/successions. + /// + private static SysmlWorkspace BuildTwoRootWorkspace() + { + var processA = new SysmlDefinitionNode + { + Name = "ProcessA", + QualifiedName = "M::ProcessA", + DefinitionKeyword = "action def", + Children = + [ + new SysmlFeatureNode { Name = "a1", QualifiedName = "M::ProcessA::a1", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "a2", QualifiedName = "M::ProcessA::a2", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "a3", QualifiedName = "M::ProcessA::a3", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "a4", QualifiedName = "M::ProcessA::a4", FeatureKeyword = "action" }, + new SysmlTransitionNode { Source = "a1", Target = "a2" }, + new SysmlTransitionNode { Source = "a2", Target = "a3" } + ] + }; + var processB = new SysmlDefinitionNode + { + Name = "ProcessB", + QualifiedName = "M::ProcessB", + DefinitionKeyword = "action def", + Children = + [ + new SysmlFeatureNode { Name = "b1", QualifiedName = "M::ProcessB::b1", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "b2", QualifiedName = "M::ProcessB::b2", FeatureKeyword = "action" }, + new SysmlTransitionNode { Source = "b1", Target = "b2" } + ] + }; + var unrelated = new SysmlDefinitionNode { Name = "Unrelated", QualifiedName = "M::Unrelated", DefinitionKeyword = "action def" }; + return new SysmlWorkspace + { + Declarations = new Dictionary + { + ["M::ProcessA"] = processA, + ["M::ProcessB"] = processB, + ["M::Unrelated"] = unrelated + } + }; + } + + /// + /// With no expose statement (null ViewNode), the heuristic picks the + /// definition with the highest successions/actions score (ProcessA), unchanged from + /// pre-scoping behavior — the critical --auto/no-expose regression guard. + /// + [Fact] + public void ActionFlowView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var boxes = layout.Nodes.OfType().Where(b => b.Keyword == "action").ToList(); + Assert.Contains(boxes, b => b.Label == "a1"); + Assert.Contains(boxes, b => b.Label == "a3"); + } + + /// + /// An expose edge pointing at a definition other than the heuristic's default root + /// (ProcessB, which has a lower score than ProcessA) selects ProcessB + /// as the root instead. + /// + [Fact] + public void ActionFlowView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["ProcessB"], + ResolvedEdges = [new SysmlEdge("M::V", "M::ProcessB", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var boxes = layout.Nodes.OfType().Where(b => b.Keyword == "action").ToList(); + Assert.Contains(boxes, b => b.Label == "b1"); + Assert.Contains(boxes, b => b.Label == "b2"); + Assert.DoesNotContain(boxes, b => b.Label is "a1" or "a2" or "a3" or "a4"); + } + + /// + /// An expose edge pointing at an inner child of a non-heuristic root + /// (M::ProcessB::b1) still selects ProcessB as the root, since the exposed + /// subject lies within the candidate root's own containment subtree. + /// + [Fact] + public void ActionFlowView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["b1"], + ResolvedEdges = [new SysmlEdge("M::V", "M::ProcessB::b1", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var boxes = layout.Nodes.OfType().Where(b => b.Keyword == "action").ToList(); + Assert.Contains(boxes, b => b.Label == "b1"); + } + + /// + /// An expose edge pointing at a definition unrelated to every candidate root makes no + /// root scope-relevant, so no root is chosen and an empty canvas results. + /// + [Fact] + public void ActionFlowView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["Unrelated"], + ResolvedEdges = [new SysmlEdge("M::V", "M::Unrelated", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + Assert.Empty(layout.Nodes); + } + + /// + /// Exposing action a1 of ProcessA narrows the declared actions to those in + /// scope: the isolated declared action a4 (never referenced by a succession) is + /// dropped, while a2/a3 remain because they are re-synthesized from the + /// a1->a2->a3 succession endpoints (the existing + /// synthesized-action mechanism, unaffected by scope, per the containment design). This + /// produces strictly fewer action boxes than the unscoped rendering. + /// + [Fact] + public void ActionFlowView_BuildLayout_ExposeSingleAction_DropsOutOfScopeAction() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["a1"], + ResolvedEdges = [new SysmlEdge("M::V", "M::ProcessA::a1", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var scoped = strategy.BuildLayout(context, options); + var full = strategy.BuildLayout(new ViewContext("full", workspace), options); + + var scopedLabels = scoped.Nodes.OfType().Where(b => b.Keyword == "action").Select(b => b.Label).ToList(); + var fullLabels = full.Nodes.OfType().Where(b => b.Keyword == "action").Select(b => b.Label).ToList(); + + Assert.Contains("a1", scopedLabels); + Assert.Contains("a2", scopedLabels); + Assert.Contains("a3", scopedLabels); + Assert.DoesNotContain("a4", scopedLabels); + Assert.True(scopedLabels.Count < fullLabels.Count, $"expected scoped ({scopedLabels.Count}) < full ({fullLabels.Count})"); + } + + /// + /// An expose edge that resolves to a feature usage (not a definition) still selects + /// the definition it types via the shared usage-to-type fallback in + /// ExposeScopeResolver.ResolveExposedScope: exposing a usage myProcess typed + /// by ProcessB selects ProcessB as the root. + /// + [Fact] + public void ActionFlowView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + workspace.AddDeclaration("M::myProcess", new SysmlFeatureNode + { + Name = "myProcess", + QualifiedName = "M::myProcess", + FeatureTyping = "ProcessB", + ResolvedEdges = [new SysmlEdge("M::myProcess", "M::ProcessB", SysmlEdgeKind.Typing)] + }); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["myProcess"], + ResolvedEdges = [new SysmlEdge("M::V", "M::myProcess", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var boxes = layout.Nodes.OfType().Where(b => b.Keyword == "action").ToList(); + Assert.Contains(boxes, b => b.Label == "b1"); + Assert.Contains(boxes, b => b.Label == "b2"); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs index 9c88acdf..1255c802 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs @@ -87,4 +87,270 @@ public void BrowserAndGrid_BuildLayout_EmptyWorkspace_ReturnMinimalCanvas() Assert.Empty(new BrowserViewLayoutStrategy().BuildLayout(context, options).Nodes); Assert.Empty(new GridViewLayoutStrategy().BuildLayout(context, options).Nodes); } + + /// + /// Builds the fixed three-definition workspace shared by the expose-scoping tests: + /// Root::A (an expose target), Root::A::Child (inside A's containment + /// subtree), and Root::B (an unrelated sibling definition, outside A's + /// subtree). + /// + private static SysmlWorkspace BuildScopingWorkspace() => new() + { + Declarations = new Dictionary + { + ["Root::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Root::A", DefinitionKeyword = "part def" }, + ["Root::A::Child"] = new SysmlDefinitionNode { Name = "Child", QualifiedName = "Root::A::Child", DefinitionKeyword = "part def" }, + ["Root::B"] = new SysmlDefinitionNode { Name = "B", QualifiedName = "Root::B", DefinitionKeyword = "part def" } + } + }; + + /// + /// Builds a workspace modeling the usage-vs-definition containment gap: a + /// Root::Vehicle definition with an owned child Root::Vehicle::Engine, and a + /// Root::myVehicle feature usage typed by Vehicle (a resolved Typing + /// edge to Root::Vehicle) plus an unrelated sibling Root::Other. + /// + private static SysmlWorkspace BuildUsageTypingWorkspace() => new() + { + Declarations = new Dictionary + { + ["Root::Vehicle"] = new SysmlDefinitionNode { Name = "Vehicle", QualifiedName = "Root::Vehicle", DefinitionKeyword = "part def" }, + ["Root::Vehicle::Engine"] = new SysmlDefinitionNode { Name = "Engine", QualifiedName = "Root::Vehicle::Engine", DefinitionKeyword = "part def" }, + ["Root::myVehicle"] = new SysmlFeatureNode + { + Name = "myVehicle", + QualifiedName = "Root::myVehicle", + FeatureTyping = "Vehicle", + ResolvedEdges = [new SysmlEdge("Root::myVehicle", "Root::Vehicle", SysmlEdgeKind.Typing)] + }, + ["Root::Other"] = new SysmlDefinitionNode { Name = "Other", QualifiedName = "Root::Other", DefinitionKeyword = "part def" } + } + }; + + /// + /// A Grid View with a resolved Expose edge to Root::A scopes the matrix to + /// Root::A plus its containment subtree (Root::A::Child), excluding the + /// unrelated sibling Root::B — producing fewer rows/columns than rendering the full + /// workspace. + /// + [Fact] + public void GridView_BuildLayout_ExposedName_UnionsAdditionalSubtree() + { + var strategy = new GridViewLayoutStrategy(); + var workspace = BuildScopingWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposedNames = ["A"], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var scoped = strategy.BuildLayout(context, options); + var full = strategy.BuildLayout(new ViewContext("full", workspace), options); + + var scopedGrid = Assert.Single(scoped.Nodes.OfType()); + var fullGrid = Assert.Single(full.Nodes.OfType()); + var scopedLabels = scopedGrid.Rows[0].Cells.Select(c => c.Text).ToList(); + Assert.Contains("A", scopedLabels); + Assert.Contains("Child", scopedLabels); + Assert.DoesNotContain("B", scopedLabels); + Assert.True(scopedGrid.Rows.Count < fullGrid.Rows.Count); + } + + /// + /// With no expose statement (null ViewNode), the Grid View renders every + /// non-stdlib definition unchanged — the critical --auto/no-expose regression guard. + /// + [Fact] + public void GridView_BuildLayout_NullViewNode_RendersFullWorkspaceUnchanged() + { + var strategy = new GridViewLayoutStrategy(); + var workspace = BuildScopingWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var grid = Assert.Single(layout.Nodes.OfType()); + var labels = grid.Rows[0].Cells.Select(c => c.Text).ToList(); + Assert.Contains("A", labels); + Assert.Contains("Child", labels); + Assert.Contains("B", labels); + } + + /// + /// A Grid View whose Expose edge resolves to a feature usage (not a definition) + /// still renders that usage's type's containment subtree, via the shared usage-to-type + /// fallback in ExposeScopeResolver.ResolveExposedScope. + /// + [Fact] + public void GridView_BuildLayout_ExposedUsage_ResolvesThroughTypingToDefinitionSubtree() + { + var strategy = new GridViewLayoutStrategy(); + var workspace = BuildUsageTypingWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposedNames = ["myVehicle"], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::myVehicle", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var grid = Assert.Single(layout.Nodes.OfType()); + var labels = grid.Rows[0].Cells.Select(c => c.Text).ToList(); + Assert.Contains("Vehicle", labels); + Assert.Contains("Engine", labels); + Assert.DoesNotContain("Other", labels); + } + + /// + /// A Grid View expose statement naming two separate definitions unions both their + /// containment subtrees. + /// + [Fact] + public void GridView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees() + { + var strategy = new GridViewLayoutStrategy(); + var workspace = BuildScopingWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposedNames = ["A", "B"], + ResolvedEdges = + [ + new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose), + new SysmlEdge("Root::V", "Root::B", SysmlEdgeKind.Expose) + ] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var grid = Assert.Single(layout.Nodes.OfType()); + var labels = grid.Rows[0].Cells.Select(c => c.Text).ToList(); + Assert.Contains("A", labels); + Assert.Contains("Child", labels); + Assert.Contains("B", labels); + } + + /// + /// A Browser View with a resolved Expose edge to Root::A scopes the tree to + /// Root::A's containment subtree, excluding the unrelated sibling Root::B — + /// Root::A itself is promoted to a forest root since its own parent (Root) has + /// no declaration in the workspace and is thus not part of the filtered names list. + /// + [Fact] + public void BrowserView_BuildLayout_ExposedName_UnionsAdditionalSubtree() + { + var strategy = new BrowserViewLayoutStrategy(); + var workspace = BuildScopingWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposedNames = ["A"], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var scoped = strategy.BuildLayout(context, options); + var full = strategy.BuildLayout(new ViewContext("full", workspace), options); + + var scopedLabels = scoped.Nodes.OfType().Select(b => b.Label).ToList(); + var fullLabels = full.Nodes.OfType().Select(b => b.Label).ToList(); + Assert.Contains(scopedLabels, l => l!.Contains("A") && !l.Contains("Child")); + Assert.Contains(scopedLabels, l => l!.Contains("Child")); + Assert.DoesNotContain(scopedLabels, l => l!.Contains("B") && !l.Contains("A") && !l.Contains("Child")); + Assert.True(scopedLabels.Count < fullLabels.Count, + $"expected scoped ({scopedLabels.Count}) < full ({fullLabels.Count})"); + } + + /// + /// With no expose statement (null ViewNode), the Browser View renders the + /// full membership forest unchanged — the critical --auto/no-expose regression guard. + /// + [Fact] + public void BrowserView_BuildLayout_NullViewNode_RendersFullWorkspaceUnchanged() + { + var strategy = new BrowserViewLayoutStrategy(); + var workspace = BuildScopingWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var labels = layout.Nodes.OfType().Select(b => b.Label).ToList(); + Assert.Contains(labels, l => l!.Contains("A") && !l.Contains("Child")); + Assert.Contains(labels, l => l!.Contains("Child")); + Assert.Contains(labels, l => l!.Contains("B") && !l.Contains("A") && !l.Contains("Child")); + } + + /// + /// A Browser View whose Expose edge resolves to a feature usage (not a definition) + /// still renders that usage's type's containment subtree, via the shared usage-to-type + /// fallback in ExposeScopeResolver.ResolveExposedScope. + /// + [Fact] + public void BrowserView_BuildLayout_ExposedUsage_ResolvesThroughTypingToDefinitionSubtree() + { + var strategy = new BrowserViewLayoutStrategy(); + var workspace = BuildUsageTypingWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposedNames = ["myVehicle"], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::myVehicle", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var labels = layout.Nodes.OfType().Select(b => b.Label).ToList(); + Assert.Contains(labels, l => l!.Contains("Vehicle") && !l.Contains("Engine")); + Assert.Contains(labels, l => l!.Contains("Engine")); + Assert.DoesNotContain(labels, l => l!.Contains("Other")); + } + + /// + /// A Browser View expose statement naming two separate definitions unions both their + /// containment subtrees, each becoming (or remaining under) a forest root. + /// + [Fact] + public void BrowserView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees() + { + var strategy = new BrowserViewLayoutStrategy(); + var workspace = BuildScopingWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposedNames = ["A", "B"], + ResolvedEdges = + [ + new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose), + new SysmlEdge("Root::V", "Root::B", SysmlEdgeKind.Expose) + ] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var labels = layout.Nodes.OfType().Select(b => b.Label).ToList(); + Assert.Contains(labels, l => l!.Contains("A") && !l.Contains("Child")); + Assert.Contains(labels, l => l!.Contains("Child")); + Assert.Contains(labels, l => l!.Contains("B") && !l.Contains("A") && !l.Contains("Child")); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs new file mode 100644 index 00000000..9d34e5b5 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs @@ -0,0 +1,180 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.SysML2Tools.Layout.Internal; +using DemaConsulting.SysML2Tools.Semantic; +using DemaConsulting.SysML2Tools.Semantic.Model; + +namespace DemaConsulting.SysML2Tools.Tests.Layout; + +/// +/// Direct unit tests for the shared used by every layout +/// strategy to honor expose scoping. +/// +public sealed class ExposeScopeResolverTests +{ + /// + /// A null ViewNode (the synthetic --auto view) resolves to null scope, + /// meaning "no scoping applies" — every non-stdlib element is included. + /// + [Fact] + public void ResolveExposedScope_NullViewNode_ReturnsNull() + { + var workspace = new SysmlWorkspace(); + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, null); + + Assert.Null(scope); + } + + /// + /// A ViewNode with no resolved Expose edges (no expose statement, or + /// every entry failed to resolve) resolves to null scope. + /// + [Fact] + public void ResolveExposedScope_NoResolvedExposeEdges_ReturnsNull() + { + var workspace = new SysmlWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ResolvedEdges = [] + }; + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); + + Assert.Null(scope); + } + + /// + /// A resolved Expose edge targeting a definition resolves to a scope containing + /// exactly that definition's qualified name. + /// + [Fact] + public void ResolveExposedScope_ExposedDefinition_ReturnsThatQualifiedName() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Root::A", DefinitionKeyword = "part def" } + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ResolvedEdges = [new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose)] + }; + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); + + Assert.NotNull(scope); + Assert.Equal(["Root::A"], scope); + } + + /// + /// When an exposed target resolves to a feature usage (not a definition), the resolved + /// scope also includes the usage's own Typing edge target, so the type's + /// containment subtree is included alongside the usage itself (usage-to-type fallback). + /// + [Fact] + public void ResolveExposedScope_ExposedUsage_AlsoIncludesResolvedTypeTarget() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::Vehicle"] = new SysmlDefinitionNode { Name = "Vehicle", QualifiedName = "Root::Vehicle", DefinitionKeyword = "part def" }, + ["Root::myVehicle"] = new SysmlFeatureNode + { + Name = "myVehicle", + QualifiedName = "Root::myVehicle", + FeatureTyping = "Vehicle", + ResolvedEdges = [new SysmlEdge("Root::myVehicle", "Root::Vehicle", SysmlEdgeKind.Typing)] + } + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ResolvedEdges = [new SysmlEdge("Root::V", "Root::myVehicle", SysmlEdgeKind.Expose)] + }; + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); + + Assert.NotNull(scope); + Assert.Contains("Root::myVehicle", scope); + Assert.Contains("Root::Vehicle", scope); + } + + /// An exact qualified-name match is in scope. + [Fact] + public void IsInSubjectScope_ExactMatch_ReturnsTrue() + { + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A", ["Root::A"])); + } + + /// A qualified name nested under a subject's containment subtree is in scope. + [Fact] + public void IsInSubjectScope_SubtreeMatch_ReturnsTrue() + { + Assert.True(ExposeScopeResolver.IsInSubjectScope("Root::A::Child", ["Root::A"])); + } + + /// + /// A qualified name that merely shares a string prefix with a subject, without the + /// "::" separator, is not considered a subtree match (e.g. Root::AB is not in + /// scope for subject Root::A). + /// + [Fact] + public void IsInSubjectScope_PrefixWithoutSeparator_ReturnsFalse() + { + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::AB", ["Root::A"])); + } + + /// An unrelated qualified name is not in scope. + [Fact] + public void IsInSubjectScope_UnrelatedName_ReturnsFalse() + { + Assert.False(ExposeScopeResolver.IsInSubjectScope("Root::B", ["Root::A"])); + } + + /// A candidate root that is itself an exposed subject is relevant to the scope. + [Fact] + public void IsRootRelevantToScope_CandidateEqualsSubject_ReturnsTrue() + { + Assert.True(ExposeScopeResolver.IsRootRelevantToScope("Root::A", ["Root::A"])); + } + + /// + /// A candidate root nested within an exposed subject's containment subtree is relevant to + /// the scope. + /// + [Fact] + public void IsRootRelevantToScope_CandidateNestedInSubject_ReturnsTrue() + { + Assert.True(ExposeScopeResolver.IsRootRelevantToScope("Root::A::Child", ["Root::A"])); + } + + /// + /// A candidate root that contains an exposed subject within its own containment subtree + /// (the common "expose an inner element of the heuristic root" case) is relevant to the + /// scope. + /// + [Fact] + public void IsRootRelevantToScope_SubjectNestedInCandidate_ReturnsTrue() + { + Assert.True(ExposeScopeResolver.IsRootRelevantToScope("Root::A", ["Root::A::Child"])); + } + + /// A candidate root unrelated to any exposed subject is not relevant to the scope. + [Fact] + public void IsRootRelevantToScope_UnrelatedCandidate_ReturnsFalse() + { + Assert.False(ExposeScopeResolver.IsRootRelevantToScope("Root::B", ["Root::A"])); + } +} diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs index 5d06a2e7..1d3fd0a2 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs @@ -386,4 +386,245 @@ private static bool Overlaps(LayoutBox a, LayoutBox b) => b.X < a.X + a.Width && a.Y < b.Y + b.Height && b.Y < a.Y + a.Height; + + /// + /// Builds a workspace with two candidate roots: M::SysA (two connections, three + /// parts — the heuristic's default pick) and M::SysB (one connection, two parts), + /// plus an unrelated sibling definition M::Unrelated with no parts/connections. + /// + private static SysmlWorkspace BuildTwoRootWorkspace() + { + var sysA = new SysmlDefinitionNode + { + Name = "SysA", + QualifiedName = "M::SysA", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "a1", QualifiedName = "M::SysA::a1", FeatureKeyword = "part", FeatureTyping = "A" }, + new SysmlFeatureNode { Name = "a2", QualifiedName = "M::SysA::a2", FeatureKeyword = "part", FeatureTyping = "A" }, + new SysmlFeatureNode { Name = "a3", QualifiedName = "M::SysA::a3", FeatureKeyword = "part", FeatureTyping = "A" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "a1", EndpointB = "a2" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "a2", EndpointB = "a3" } + ] + }; + var sysB = new SysmlDefinitionNode + { + Name = "SysB", + QualifiedName = "M::SysB", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "b1", QualifiedName = "M::SysB::b1", FeatureKeyword = "part", FeatureTyping = "B" }, + new SysmlFeatureNode { Name = "b2", QualifiedName = "M::SysB::b2", FeatureKeyword = "part", FeatureTyping = "B" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "b1", EndpointB = "b2" } + ] + }; + var unrelated = new SysmlDefinitionNode { Name = "Unrelated", QualifiedName = "M::Unrelated", DefinitionKeyword = "part def" }; + return new SysmlWorkspace + { + Declarations = new Dictionary + { + ["M::SysA"] = sysA, + ["M::SysB"] = sysB, + ["M::Unrelated"] = unrelated + } + }; + } + + /// + /// With no expose statement (null ViewNode), the heuristic picks the + /// definition with the most connections (SysA), unchanged from pre-scoping behavior + /// — the critical --auto/no-expose regression guard. + /// + [Fact] + public void InterconnectionView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged() + { + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + Assert.Equal("SysA", container.Label); + } + + /// + /// An expose edge pointing at a definition other than the heuristic's default root + /// (SysB, which has fewer connections than SysA) selects SysB as the + /// root instead, proving FindRoot restricts candidates to scope-relevant ones before + /// the connections/parts tie-break applies. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot() + { + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["SysB"], + ResolvedEdges = [new SysmlEdge("M::V", "M::SysB", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + Assert.Equal("SysB", container.Label); + } + + /// + /// An expose edge pointing at an inner child of a non-heuristic root + /// (M::SysB::b1) still selects SysB as the root, since the exposed subject + /// lies within the candidate root's own containment subtree. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot() + { + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["b1"], + ResolvedEdges = [new SysmlEdge("M::V", "M::SysB::b1", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + Assert.Equal("SysB", container.Label); + + // And the part collection is narrowed to just b1 (b2 dropped, connection dropped with it). + var partBoxes = layout.Nodes.OfType().Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); + Assert.Single(partBoxes); + Assert.Empty(layout.Nodes.OfType()); + } + + /// + /// An expose edge pointing at a definition unrelated to every candidate root (no + /// containment relationship in either direction) makes no root scope-relevant, so no root + /// is chosen and an empty canvas results. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas() + { + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["Unrelated"], + ResolvedEdges = [new SysmlEdge("M::V", "M::Unrelated", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + Assert.Empty(layout.Nodes); + } + + /// + /// Exposing a single part narrows the interconnection to just that part (and drops any + /// connection referencing an excluded endpoint), producing strictly fewer part boxes than + /// the unscoped rendering of the same root. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeSinglePart_NarrowsToThatPart() + { + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["a1"], + ResolvedEdges = [new SysmlEdge("M::V", "M::SysA::a1", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var scoped = strategy.BuildLayout(context, options); + var full = strategy.BuildLayout(new ViewContext("full", workspace), options); + + var scopedParts = scoped.Nodes.OfType().Count(b => b.Shape == BoxShape.RoundedRectangle); + var fullParts = full.Nodes.OfType().Count(b => b.Shape == BoxShape.RoundedRectangle); + Assert.True(scopedParts < fullParts, $"expected scoped ({scopedParts}) < full ({fullParts})"); + Assert.Single(scoped.Nodes.OfType(), b => b.Shape == BoxShape.RoundedRectangle); + } + + /// + /// An expose statement naming two separate parts of the same root includes both + /// (the union of every exposed target's containment subtree), and their connection is kept + /// since both endpoints remain in scope. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeMultipleParts_UnionsBothSubtrees() + { + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["a1", "a2"], + ResolvedEdges = + [ + new SysmlEdge("M::V", "M::SysA::a1", SysmlEdgeKind.Expose), + new SysmlEdge("M::V", "M::SysA::a2", SysmlEdgeKind.Expose) + ] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var partBoxes = layout.Nodes.OfType().Where(b => b.Shape == BoxShape.RoundedRectangle).ToList(); + Assert.Equal(2, partBoxes.Count); + Assert.Single(layout.Nodes.OfType()); + } + + /// + /// An expose edge that resolves to a feature usage (not a definition) still narrows + /// the interconnection to that usage's containment subtree via the shared usage-to-type + /// fallback in ExposeScopeResolver.ResolveExposedScope: exposing a usage + /// myPart typed by SysA selects SysA as the root. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot() + { + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + workspace.AddDeclaration("M::myPart", new SysmlFeatureNode + { + Name = "myPart", + QualifiedName = "M::myPart", + FeatureTyping = "SysB", + ResolvedEdges = [new SysmlEdge("M::myPart", "M::SysB", SysmlEdgeKind.Typing)] + }); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["myPart"], + ResolvedEdges = [new SysmlEdge("M::V", "M::myPart", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + Assert.Equal("SysB", container.Label); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs index 63cfb922..b1979921 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs @@ -94,7 +94,9 @@ public void SequenceView_BuildLayout_Message_IsHorizontalBetweenLifelines() Assert.Equal(EndMarkerStyle.OpenChevron, line.TargetEnd); } - /// A workspace with no messages yields a minimal canvas. + /// + /// A workspace with no messages yields a minimal canvas. + /// [Fact] public void SequenceView_BuildLayout_NoMessages_ReturnsMinimalCanvas() { @@ -141,4 +143,296 @@ public void SequenceView_BuildLayout_MessageArrow_HasOpenArrowhead() Assert.Equal(EndMarkerStyle.OpenChevron, line.TargetEnd); Assert.Equal(EndMarkerStyle.None, line.SourceEnd); } + + /// + /// Confirms Assumption 4 of the expose-scoping plan for Sequence View: reconstructing a + /// lifeline's absolute qualified name as "{root.QualifiedName}::{lifelineName}" + /// matches a genuinely declared feature's own QualifiedName in a realistic model — + /// part client { ... } declared directly under the root part def, referenced by a + /// message endpoint's first dotted segment (client.sendRequest) — mirroring + /// test/SysMLModels/Custom/client-server-sequence.sysml. This validates that + /// may safely use the reconstructed name with + /// ExposeScopeResolver.IsInSubjectScope for lifeline-level scope filtering. + /// + [Fact] + public void SequenceView_LifelineQualifiedNameReconstruction_MatchesDeclaredFeature() + { + // Arrange: a Protocol part def declaring client/server parts and a message between them, + // mirroring the real client-server-sequence.sysml fixture. + var root = new SysmlDefinitionNode + { + Name = "Protocol", + QualifiedName = "ClientServerProtocol::Protocol", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "client", QualifiedName = "ClientServerProtocol::Protocol::client", FeatureKeyword = "part" }, + new SysmlFeatureNode { Name = "server", QualifiedName = "ClientServerProtocol::Protocol::server", FeatureKeyword = "part" }, + new SysmlConnectionNode + { + Name = "request", + ConnectionKeyword = "message", + EndpointA = "client.sendRequest", + EndpointB = "server.getRequest" + } + ] + }; + + // Act: reconstruct the lifeline qualified name the strategy uses ("client", the message + // endpoint's first dotted segment) and confirm it equals the actually-declared feature's + // own QualifiedName. + var reconstructed = $"{root.QualifiedName}::client"; + var declaredClient = root.Children.OfType().Single(f => f.Name == "client"); + + // Assert + Assert.Equal(declaredClient.QualifiedName, reconstructed); + } + + /// + /// Builds a workspace with two candidate roots: M::ProtocolA (two messages — the + /// heuristic's default pick, with lifelines client/server declared directly + /// under it) and M::ProtocolB (one message, lifelines x/y), plus an + /// unrelated sibling definition M::Unrelated with no messages. + /// + private static SysmlWorkspace BuildTwoRootWorkspace() + { + var protocolA = new SysmlDefinitionNode + { + Name = "ProtocolA", + QualifiedName = "M::ProtocolA", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "client", QualifiedName = "M::ProtocolA::client", FeatureKeyword = "part" }, + new SysmlFeatureNode { Name = "server", QualifiedName = "M::ProtocolA::server", FeatureKeyword = "part" }, + new SysmlConnectionNode { Name = "req", ConnectionKeyword = "message", EndpointA = "client.a", EndpointB = "server.b" }, + new SysmlConnectionNode { Name = "resp", ConnectionKeyword = "message", EndpointA = "server.c", EndpointB = "client.d" }, + new SysmlConnectionNode { Name = "self", ConnectionKeyword = "message", EndpointA = "server.e", EndpointB = "server.f" } + ] + }; + var protocolB = new SysmlDefinitionNode + { + Name = "ProtocolB", + QualifiedName = "M::ProtocolB", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "x", QualifiedName = "M::ProtocolB::x", FeatureKeyword = "part" }, + new SysmlFeatureNode { Name = "y", QualifiedName = "M::ProtocolB::y", FeatureKeyword = "part" }, + new SysmlConnectionNode { Name = "m", ConnectionKeyword = "message", EndpointA = "x.p", EndpointB = "y.q" }, + new SysmlConnectionNode { Name = "self", ConnectionKeyword = "message", EndpointA = "x.s", EndpointB = "x.t" } + ] + }; + var unrelated = new SysmlDefinitionNode { Name = "Unrelated", QualifiedName = "M::Unrelated", DefinitionKeyword = "part def" }; + return new SysmlWorkspace + { + Declarations = new Dictionary + { + ["M::ProtocolA"] = protocolA, + ["M::ProtocolB"] = protocolB, + ["M::Unrelated"] = unrelated + } + }; + } + + /// + /// With no expose statement (null ViewNode), the heuristic picks the + /// definition with the most messages (ProtocolA), unchanged from pre-scoping + /// behavior — the critical --auto/no-expose regression guard. + /// + [Fact] + public void SequenceView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged() + { + var strategy = new SequenceViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var lifelines = layout.Nodes.OfType().Select(l => l.Label).ToList(); + Assert.Contains("client", lifelines); + Assert.Contains("server", lifelines); + } + + /// + /// An expose edge pointing at a definition other than the heuristic's default root + /// (ProtocolB, which has fewer messages than ProtocolA) selects + /// ProtocolB as the root instead. + /// + [Fact] + public void SequenceView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot() + { + var strategy = new SequenceViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["ProtocolB"], + ResolvedEdges = [new SysmlEdge("M::V", "M::ProtocolB", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var lifelines = layout.Nodes.OfType().Select(l => l.Label).ToList(); + Assert.Contains("x", lifelines); + Assert.Contains("y", lifelines); + Assert.DoesNotContain("client", lifelines); + Assert.DoesNotContain("server", lifelines); + } + + /// + /// An expose edge pointing at an inner child of a non-heuristic root + /// (M::ProtocolB::x) still selects ProtocolB as the root, since the exposed + /// subject lies within the candidate root's own containment subtree. + /// + [Fact] + public void SequenceView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot() + { + var strategy = new SequenceViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["x"], + ResolvedEdges = [new SysmlEdge("M::V", "M::ProtocolB::x", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var lifelines = layout.Nodes.OfType().Select(l => l.Label).ToList(); + Assert.Contains("x", lifelines); + } + + /// + /// An expose edge pointing at a definition unrelated to every candidate root makes no + /// root scope-relevant, so no root is chosen and an empty canvas results. + /// + [Fact] + public void SequenceView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas() + { + var strategy = new SequenceViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["Unrelated"], + ResolvedEdges = [new SysmlEdge("M::V", "M::Unrelated", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + Assert.Empty(layout.Nodes); + } + + /// + /// Exposing lifeline server of ProtocolA narrows the diagram to lifelines whose + /// reconstructed qualified name is in scope (client is dropped), which in turn drops + /// the req/resp messages that reference the excluded client lifeline — + /// proving the existing ResolveMessages endpoint-drop mechanism, not new edge-side + /// logic, governs which messages survive filtering. The self message (both endpoints + /// on server) remains since neither of its endpoints was excluded, producing strictly + /// fewer lifelines than the unscoped rendering. + /// + [Fact] + public void SequenceView_BuildLayout_ExposeSingleLifeline_NarrowsLifelines() + { + var strategy = new SequenceViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["server"], + ResolvedEdges = [new SysmlEdge("M::V", "M::ProtocolA::server", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var scoped = strategy.BuildLayout(context, options); + var full = strategy.BuildLayout(new ViewContext("full", workspace), options); + + var scopedLifelines = scoped.Nodes.OfType().Select(l => l.Label).ToList(); + var fullLifelines = full.Nodes.OfType().Select(l => l.Label).ToList(); + + Assert.Contains("server", scopedLifelines); + Assert.DoesNotContain("client", scopedLifelines); + Assert.True(scopedLifelines.Count < fullLifelines.Count, + $"expected scoped ({scopedLifelines.Count}) < full ({fullLifelines.Count})"); + } + + /// + /// An expose edge naming both lifelines of the same root includes both (the union of + /// every exposed target's containment subtree) and retains every message between them. + /// + [Fact] + public void SequenceView_BuildLayout_ExposeBothLifelines_UnionsSubtreesKeepsMessages() + { + var strategy = new SequenceViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["client", "server"], + ResolvedEdges = + [ + new SysmlEdge("M::V", "M::ProtocolA::client", SysmlEdgeKind.Expose), + new SysmlEdge("M::V", "M::ProtocolA::server", SysmlEdgeKind.Expose) + ] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var lifelines = layout.Nodes.OfType().Select(l => l.Label).ToList(); + Assert.Contains("client", lifelines); + Assert.Contains("server", lifelines); + Assert.Equal(3, layout.Nodes.OfType().Count()); + } + + /// + /// An expose edge that resolves to a feature usage (not a definition) still selects + /// the definition it types via the shared usage-to-type fallback in + /// ExposeScopeResolver.ResolveExposedScope: exposing a usage myProtocol typed + /// by ProtocolB selects ProtocolB as the root. + /// + [Fact] + public void SequenceView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot() + { + var strategy = new SequenceViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + workspace.AddDeclaration("M::myProtocol", new SysmlFeatureNode + { + Name = "myProtocol", + QualifiedName = "M::myProtocol", + FeatureTyping = "ProtocolB", + ResolvedEdges = [new SysmlEdge("M::myProtocol", "M::ProtocolB", SysmlEdgeKind.Typing)] + }); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["myProtocol"], + ResolvedEdges = [new SysmlEdge("M::V", "M::myProtocol", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var lifelines = layout.Nodes.OfType().Select(l => l.Label).ToList(); + Assert.Contains("x", lifelines); + Assert.Contains("y", lifelines); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs index aa24225c..498e898a 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs @@ -232,4 +232,218 @@ public void StateTransitionView_BuildLayout_ForwardChain_FlowsTopToBottomOrthogo } } } + + /// + /// Builds a workspace with two candidate roots: SM::MachineA (two transitions — the + /// heuristic's default pick, one of its declared states s3 is isolated with no + /// transitions), SM::MachineB (one transition), and an unrelated sibling definition + /// SM::Unrelated with no transitions. + /// + private static SysmlWorkspace BuildTwoRootWorkspace() + { + var machineA = new SysmlDefinitionNode + { + Name = "MachineA", + QualifiedName = "SM::MachineA", + DefinitionKeyword = "state def", + Children = + [ + new SysmlFeatureNode { Name = "s1", QualifiedName = "SM::MachineA::s1", FeatureKeyword = "state" }, + new SysmlFeatureNode { Name = "s2", QualifiedName = "SM::MachineA::s2", FeatureKeyword = "state" }, + new SysmlFeatureNode { Name = "s3", QualifiedName = "SM::MachineA::s3", FeatureKeyword = "state" }, + new SysmlTransitionNode { Source = "s1", Target = "s2", Guard = "g1" }, + new SysmlTransitionNode { Source = "s2", Target = "s1", Guard = "g2" } + ] + }; + var machineB = new SysmlDefinitionNode + { + Name = "MachineB", + QualifiedName = "SM::MachineB", + DefinitionKeyword = "state def", + Children = + [ + new SysmlFeatureNode { Name = "b1", QualifiedName = "SM::MachineB::b1", FeatureKeyword = "state" }, + new SysmlFeatureNode { Name = "b2", QualifiedName = "SM::MachineB::b2", FeatureKeyword = "state" }, + new SysmlTransitionNode { Source = "b1", Target = "b2", Guard = "g" } + ] + }; + var unrelated = new SysmlDefinitionNode { Name = "Unrelated", QualifiedName = "SM::Unrelated", DefinitionKeyword = "state def" }; + return new SysmlWorkspace + { + Declarations = new Dictionary + { + ["SM::MachineA"] = machineA, + ["SM::MachineB"] = machineB, + ["SM::Unrelated"] = unrelated + } + }; + } + + /// + /// With no expose statement (null ViewNode), the heuristic picks the + /// definition with the most transitions (MachineA), unchanged from pre-scoping + /// behavior — the critical --auto/no-expose regression guard. + /// + [Fact] + public void StateTransitionView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged() + { + var strategy = new StateTransitionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var boxes = layout.Nodes.OfType().Where(b => b.Keyword == "state").ToList(); + Assert.Contains(boxes, b => b.Label == "s1"); + Assert.Contains(boxes, b => b.Label == "s2"); + } + + /// + /// An expose edge pointing at a definition other than the heuristic's default root + /// (MachineB, which has fewer transitions than MachineA) selects + /// MachineB as the root instead. + /// + [Fact] + public void StateTransitionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot() + { + var strategy = new StateTransitionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "SM::V", + ExposedNames = ["MachineB"], + ResolvedEdges = [new SysmlEdge("SM::V", "SM::MachineB", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var boxes = layout.Nodes.OfType().Where(b => b.Keyword == "state").ToList(); + Assert.Contains(boxes, b => b.Label == "b1"); + Assert.Contains(boxes, b => b.Label == "b2"); + Assert.DoesNotContain(boxes, b => b.Label is "s1" or "s2" or "s3"); + } + + /// + /// An expose edge pointing at an inner child of a non-heuristic root + /// (SM::MachineB::b1) still selects MachineB as the root, since the exposed + /// subject lies within the candidate root's own containment subtree. + /// + [Fact] + public void StateTransitionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot() + { + var strategy = new StateTransitionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "SM::V", + ExposedNames = ["b1"], + ResolvedEdges = [new SysmlEdge("SM::V", "SM::MachineB::b1", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var boxes = layout.Nodes.OfType().Where(b => b.Keyword == "state").ToList(); + Assert.Contains(boxes, b => b.Label == "b1"); + } + + /// + /// An expose edge pointing at a definition unrelated to every candidate root makes no + /// root scope-relevant, so no root is chosen and an empty canvas results. + /// + [Fact] + public void StateTransitionView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas() + { + var strategy = new StateTransitionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "SM::V", + ExposedNames = ["Unrelated"], + ResolvedEdges = [new SysmlEdge("SM::V", "SM::Unrelated", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + Assert.Empty(layout.Nodes); + } + + /// + /// Exposing state s1 of MachineA narrows the declared states to those in + /// scope: the isolated declared state s3 (never referenced by a transition) is + /// dropped, while s2 remains because it is re-synthesized from the s1-> + /// s2 transition endpoint (the existing synthesized-state mechanism, unaffected by + /// scope, per the containment design). This produces strictly fewer state boxes than the + /// unscoped rendering. + /// + [Fact] + public void StateTransitionView_BuildLayout_ExposeSingleState_DropsIsolatedOutOfScopeState() + { + var strategy = new StateTransitionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "SM::V", + ExposedNames = ["s1"], + ResolvedEdges = [new SysmlEdge("SM::V", "SM::MachineA::s1", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var scoped = strategy.BuildLayout(context, options); + var full = strategy.BuildLayout(new ViewContext("full", workspace), options); + + var scopedLabels = scoped.Nodes.OfType().Where(b => b.Keyword == "state").Select(b => b.Label).ToList(); + var fullLabels = full.Nodes.OfType().Where(b => b.Keyword == "state").Select(b => b.Label).ToList(); + + Assert.Contains("s1", scopedLabels); + Assert.Contains("s2", scopedLabels); + Assert.DoesNotContain("s3", scopedLabels); + Assert.True(scopedLabels.Count < fullLabels.Count, $"expected scoped ({scopedLabels.Count}) < full ({fullLabels.Count})"); + } + + /// + /// An expose edge that resolves to a feature usage (not a definition) still selects + /// the definition it types via the shared usage-to-type fallback in + /// ExposeScopeResolver.ResolveExposedScope: exposing a usage myMachine typed + /// by MachineB selects MachineB as the root. + /// + [Fact] + public void StateTransitionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot() + { + var strategy = new StateTransitionViewLayoutStrategy(); + var workspace = BuildTwoRootWorkspace(); + workspace.AddDeclaration("SM::myMachine", new SysmlFeatureNode + { + Name = "myMachine", + QualifiedName = "SM::myMachine", + FeatureTyping = "MachineB", + ResolvedEdges = [new SysmlEdge("SM::myMachine", "SM::MachineB", SysmlEdgeKind.Typing)] + }); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "SM::V", + ExposedNames = ["myMachine"], + ResolvedEdges = [new SysmlEdge("SM::V", "SM::myMachine", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var boxes = layout.Nodes.OfType().Where(b => b.Keyword == "state").ToList(); + Assert.Contains(boxes, b => b.Label == "b1"); + Assert.Contains(boxes, b => b.Label == "b2"); + } } From c6e135fd696385536d5220dc8e7d2de38250b165 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 6 Jul 2026 21:01:09 -0400 Subject: [PATCH 2/4] Fix root-selection tie-break for nested expose-scoped candidates IsRootRelevantToScope's reverse-containment check (an exposed subject nested within a candidate root's own containment subtree) correctly identifies the set of scope-relevant root candidates, but the four single-root strategies (Interconnection, StateTransition, ActionFlow, Sequence) then broke ties among relevant candidates purely by their existing score (transition/connection/message count etc.). Since SysML v2 permits nested definitions, an unrelated ancestor candidate could also pass the relevance check whenever the truly-intended, more deeply-nested root was exposed, and the ancestor's typically-higher score would win the tie-break -- silently ignoring the user's expose statement. Fix: when scope is non-null, compare candidates by specificity (qualified name length) first, using each strategy's original score only as a secondary tie-break among equally-specific candidates. A more deeply nested candidate that still passes IsRootRelevantToScope is by construction more precisely targeted by the expose statement. The unscoped path is unchanged. - Add IsMoreSpecificCandidate helper to ExposeScopeResolver, shared by all four FindRoot methods. - Add nested-candidate regression tests (an outer, higher-scoring root containing a nested, lower-scoring root-candidate as a member) to all four strategy test files, plus direct unit tests for the new helper in ExposeScopeResolverTests. - Update design docs, reqstream requirements (atomic, one per behavior), and verification docs for ExposeScopeResolver and the four affected strategies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../action-flow-view-layout-strategy.md | 33 +++++--- .../layout/internal/expose-scope-resolver.md | 37 ++++++--- .../interconnection-view-layout-strategy.md | 14 +++- .../internal/sequence-view-layout-strategy.md | 22 +++-- .../state-transition-view-layout-strategy.md | 36 ++++---- .../action-flow-view-layout-strategy.yaml | 15 ++++ .../internal/expose-scope-resolver.yaml | 22 +++++ .../interconnection-view-layout-strategy.yaml | 15 ++++ .../sequence-view-layout-strategy.yaml | 15 ++++ ...state-transition-view-layout-strategy.yaml | 15 ++++ .../action-flow-view-layout-strategy.md | 5 ++ .../layout/internal/expose-scope-resolver.md | 24 +++++- .../interconnection-view-layout-strategy.md | 4 + .../internal/sequence-view-layout-strategy.md | 4 + .../state-transition-view-layout-strategy.md | 4 + .../Internal/ActionFlowViewLayoutStrategy.cs | 19 ++++- .../Layout/Internal/ExposeScopeResolver.cs | 48 +++++++++++ .../InterconnectionViewLayoutStrategy.cs | 18 +++- .../Internal/SequenceViewLayoutStrategy.cs | 18 +++- .../StateTransitionViewLayoutStrategy.cs | 18 +++- .../ActionFlowViewLayoutStrategyTests.cs | 75 +++++++++++++++++ .../Layout/ExposeScopeResolverTests.cs | 47 +++++++++++ .../InterconnectionViewLayoutStrategyTests.cs | 82 +++++++++++++++++++ .../Layout/SequenceViewLayoutStrategyTests.cs | 76 +++++++++++++++++ .../StateTransitionViewLayoutStrategyTests.cs | 74 +++++++++++++++++ 25 files changed, 679 insertions(+), 61 deletions(-) diff --git a/docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md index 64b3356b..66d9c66e 100644 --- a/docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md @@ -28,7 +28,13 @@ adds the succession edges and the start/done markers, and assembles the tree. Re `FindRoot` chooses the non-standard-library definition that scores highest on successions (then actions), restricted — when a scope is resolved — to candidates for which -`ExposeScopeResolver.IsRootRelevantToScope` returns `true`. `CollectActions` gathers the declared +`ExposeScopeResolver.IsRootRelevantToScope` returns `true`. When multiple candidates are relevant +to a non-null scope (possible because a nested definition and its ancestor can both be relevant), +the most specific (deepest/longest qualified name) relevant candidate is preferred via +`ExposeScopeResolver.IsMoreSpecificCandidate`, with the succession/action score used only to break +ties among equally specific candidates; this ordering does not apply when `scope` is `null`. The +zero-successions-and-zero-actions exclusion guard (`successions > 0 || actions > 0`) is unaffected +by this change — it is applied regardless of specificity. `CollectActions` gathers the declared `action` usages, excluding — when a scope is resolved — any declared action feature whose qualified name fails `ExposeScopeResolver.IsInSubjectScope`; it then adds any additional action named only by a succession endpoint **unconditionally** (this second pass has no independent @@ -72,16 +78,21 @@ root is selected** and then narrows **which of that root's actions are shown**, `ExposeScopeResolver.IsRootRelevantToScope` accepts, so exposing the current heuristic root itself, an inner action of it, or a definition that itself contains the heuristic default all correctly select a root, while exposing an unrelated definition yields no root and thus the -minimal empty canvas. `CollectActions` then narrows the selected root's own **declared** action -features to those within the resolved scope; however, any declared-but-excluded action that is -still referenced by an in-scope succession is transparently re-added by the unconditional -succession-endpoint pass, since that pass has no independent qualified name to filter against — so -expose-scoping only reliably drops an action that is genuinely isolated (never referenced by any -succession of the selected root). `ResolveSuccessions`'s existing name-lookup approach naturally -omits any succession whose endpoint action was never added — no new edge-side logic was required. -A view with no `expose` statement (including the synthesized `--auto` view, whose `ViewNode` is -`null`) resolves no scope, so `FindRoot` considers every candidate and `CollectActions` keeps -every action, unchanged from the pre-scoping behavior. +minimal empty canvas. When more than one candidate is relevant (a nested definition and an +ancestor definition can both be relevant to the same exposed subject), +`ExposeScopeResolver.IsMoreSpecificCandidate` prefers the most deeply nested candidate, so +exposing an inner action of a nested definition correctly selects that nested definition rather +than its ancestor, even when the ancestor has a higher succession/action score. `CollectActions` +then narrows the selected root's own **declared** action features to those within the resolved +scope; however, any declared-but-excluded action that is still referenced by an in-scope +succession is transparently re-added by the unconditional succession-endpoint pass, since that +pass has no independent qualified name to filter against — so expose-scoping only reliably drops +an action that is genuinely isolated (never referenced by any succession of the selected root). +`ResolveSuccessions`'s existing name-lookup approach naturally omits any succession whose endpoint +action was never added — no new edge-side logic was required. A view with no `expose` statement +(including the synthesized `--auto` view, whose `ViewNode` is `null`) resolves no scope, so +`FindRoot` considers every candidate and `CollectActions` keeps every action, unchanged from the +pre-scoping behavior. ##### Error Handling diff --git a/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md b/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md index 46ef5e4b..48a54d4a 100644 --- a/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md +++ b/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md @@ -49,13 +49,28 @@ definition `InterconnectionViewLayoutStrategy`, `StateTransitionViewLayoutStrate heuristic) is related to the resolved `expose` scope in `subjects`, in either containment direction: the candidate itself is an exposed subject, the candidate lies within an exposed subject's containment subtree, or an exposed subject lies within the candidate's own containment -subtree (the common "expose an inner state/action/part/lifeline of the root" case). Used only by -the four single-root strategies to restrict their heuristic root selection to one relevant to the -resolved scope. +subtree (the common "expose an inner state/action/part/lifeline of the root" case). This method +identifies the *set* of scope-relevant candidates only — because SysML v2 definitions may nest, an +ancestor and one of its nested descendant definitions can both be relevant to the same resolved +scope. Disambiguating among multiple relevant candidates is delegated to +`IsMoreSpecificCandidate`, not decided by this method. + +###### `IsMoreSpecificCandidate(string candidateQualifiedName, string? currentBestQualifiedName, bool currentScoreIsBetter)` + +Decides, for the scoped case, whether `candidateQualifiedName` should replace the current best +scope-relevant root candidate (`currentBestQualifiedName`, or `null` when no candidate has been +selected yet). Specificity (containment depth) is compared first: because SysML v2 qualified names +are built by `parent::child` concatenation, any genuine descendant's qualified name is strictly +longer than its ancestors', so a longer qualified name is a safe, cheap proxy for "more deeply +nested" and always wins over a shorter one regardless of score. Each strategy's own score heuristic +(transition/connection+part/succession+action/message count) is used only as a fallback to break +ties between candidates of equal qualified-name length (e.g. unrelated siblings), via +`currentScoreIsBetter`. Used only by the four single-root strategies, in place of a plain score +comparison, whenever `scope` is non-null. ##### Error Handling -N/A — none of the three methods validate arguments or throw; a `null` `viewNode` and an empty or +N/A — none of the four methods validate arguments or throw; a `null` `viewNode` and an empty or non-existent `subjects`/workspace-declaration lookup are all treated as ordinary "no match" or "no scope" cases, not error conditions. @@ -71,13 +86,15 @@ non-existent `subjects`/workspace-declaration lookup are all treated as ordinary ##### Callers -- `GeneralViewLayoutStrategy` calls all three methods (moved here verbatim from its own former - private copies of `ResolveExposedScope` and `IsInSubjectScope`). +- `GeneralViewLayoutStrategy` calls all three scope-membership methods (moved here verbatim from + its own former private copies of `ResolveExposedScope` and `IsInSubjectScope`). - `GridViewLayoutStrategy` and `BrowserViewLayoutStrategy` call `ResolveExposedScope` and `IsInSubjectScope` to filter their workspace-wide definition/tree-node collections directly (no - single-root heuristic, so `IsRootRelevantToScope` does not apply). + single-root heuristic, so `IsRootRelevantToScope`/`IsMoreSpecificCandidate` do not apply). - `InterconnectionViewLayoutStrategy`, `StateTransitionViewLayoutStrategy`, `ActionFlowViewLayoutStrategy`, and `SequenceViewLayoutStrategy` call `ResolveExposedScope` to - compute the scope, `IsRootRelevantToScope` to restrict their heuristic root selection to a root - relevant to that scope, and `IsInSubjectScope` to filter the child elements (parts, states, - actions, lifelines) collected from the selected root. + compute the scope, `IsRootRelevantToScope` to restrict their heuristic root selection to + candidates relevant to that scope, `IsMoreSpecificCandidate` to break ties among multiple + relevant candidates by specificity (falling back to their own score only among equally specific + candidates), and `IsInSubjectScope` to filter the child elements (parts, states, actions, + lifelines) collected from the selected root. 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 f52a9eeb..2d0e87b5 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 @@ -78,7 +78,11 @@ Chooses the non-standard-library `part def` with the most connection usages (bre most part usages) as the definition whose interior to render, restricted — when a scope is resolved — to candidates for which `ExposeScopeResolver.IsRootRelevantToScope` returns `true`; returns `null` when no candidate is relevant to a non-null scope (an empty canvas results), and -falls back to considering every candidate when `scope` is `null`. +falls back to considering every candidate when `scope` is `null`. When multiple candidates are +relevant to a non-null scope (possible because a nested definition and its ancestor can both be +relevant), the most specific (deepest/longest qualified name) relevant candidate is preferred via +`ExposeScopeResolver.IsMoreSpecificCandidate`, with the connections/parts tie-break used only to +break ties among equally specific candidates; this ordering does not apply when `scope` is `null`. ###### `CollectParts(root, theme)` and `ResolveConnections(root, partIndex)` @@ -98,8 +102,12 @@ instead it restricts **which root is selected** and then narrows **which of that shown**. `FindRoot` only considers candidates `ExposeScopeResolver.IsRootRelevantToScope` accepts, so exposing the current heuristic root itself, an inner part of it, or a definition that itself contains the heuristic default all correctly select a root, while exposing an unrelated -definition yields no root and thus the minimal empty canvas. `CollectParts` then narrows the -selected root's own part features to those within the resolved scope (via +definition yields no root and thus the minimal empty canvas. When more than one candidate is +relevant (a nested definition and an ancestor definition can both be relevant to the same exposed +subject), `ExposeScopeResolver.IsMoreSpecificCandidate` prefers the most deeply nested candidate, +so exposing an inner part of a nested definition correctly selects that nested definition rather +than its ancestor, even when the ancestor has more connections/parts. `CollectParts` then narrows +the selected root's own part features to those within the resolved scope (via `ExposeScopeResolver.IsInSubjectScope`), and `ResolveConnections`'s existing "skip connections whose endpoint did not resolve" behavior transparently drops any connection touching an excluded part — no new edge-side logic was required. A view with no `expose` statement (including the diff --git a/docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md index 79d51ee1..e04910a1 100644 --- a/docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md @@ -25,7 +25,12 @@ Builds the diagram: 2. **Root selection.** `FindRoot` scans the non-stdlib declarations and chooses the definition that declares the most `message` connections, so the most message-rich definition drives the view — restricted, when a scope was resolved, to candidates for which - `ExposeScopeResolver.IsRootRelevantToScope` returns `true`. + `ExposeScopeResolver.IsRootRelevantToScope` returns `true`. When multiple candidates are + relevant to a non-null scope (possible because a nested definition and its ancestor can both be + relevant), the most specific (deepest/longest qualified name) relevant candidate is preferred via + `ExposeScopeResolver.IsMoreSpecificCandidate`, with the message-count tie-break used only to + break ties among equally specific candidates; this ordering does not apply when `scope` is + `null`. 3. **Lifeline collection.** `CollectLifelines` walks the root's messages and records the distinct participants in first-appearance order, where a participant is the first dot-separated segment of a message endpoint reference (for example `client` from `client.a`) — excluding, when a scope was @@ -57,11 +62,16 @@ other single-root strategies' approach. `FindRoot` only considers candidates `ExposeScopeResolver.IsRootRelevantToScope` accepts, so exposing the current heuristic root itself, an inner lifeline of it, or a definition that itself contains the heuristic default all correctly select a root, while exposing an unrelated definition yields no root and thus the -minimal empty canvas. `CollectLifelines` then narrows the selected root's lifelines by -reconstructing each candidate participant's qualified name as `"{root.QualifiedName}::{name}"` and -testing it with `ExposeScopeResolver.IsInSubjectScope`; this reconstruction was confirmed reliable -for realistic models — a directly-nested `part` feature under a root part definition, referenced by -a message endpoint's first dotted segment, has exactly this `QualifiedName` — by a dedicated test +minimal empty canvas. When more than one candidate is relevant (a nested definition and an +ancestor definition can both be relevant to the same exposed subject), +`ExposeScopeResolver.IsMoreSpecificCandidate` prefers the most deeply nested candidate, so +exposing an inner lifeline participant of a nested definition correctly selects that nested +definition rather than its ancestor, even when the ancestor has more messages. `CollectLifelines` +then narrows the selected root's lifelines by reconstructing each candidate participant's +qualified name as `"{root.QualifiedName}::{name}"` and testing it with +`ExposeScopeResolver.IsInSubjectScope`; this reconstruction was confirmed reliable for realistic +models — a directly-nested `part` feature under a root part definition, referenced by a message +endpoint's first dotted segment, has exactly this `QualifiedName` — by a dedicated test (`SequenceView_LifelineQualifiedNameReconstruction_MatchesDeclaredFeature`) mirroring the real `client-server-sequence.sysml` fixture, so no conservative fallback (restricting only root selection) was needed. `ResolveMessages`'s existing "skip a message whose endpoint did not resolve" diff --git a/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md index da75e3e1..91d2e715 100644 --- a/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md @@ -29,10 +29,14 @@ initial marker and the transition edges, and assembles the tree. Returns a minim `FindRoot` chooses the non-standard-library definition with the most transitions, restricted — when a scope is resolved — to candidates for which `ExposeScopeResolver.IsRootRelevantToScope` -returns `true`. `CollectStates` gathers the declared `state` usages first (preserving declaration -order so the first declared state becomes the initial state), excluding — when a scope is -resolved — any declared state feature whose qualified name fails -`ExposeScopeResolver.IsInSubjectScope`; it then adds any additional state named only by a +returns `true`. When multiple candidates are relevant to a non-null scope (possible because a +nested definition and its ancestor can both be relevant), the most specific (deepest/longest +qualified name) relevant candidate is preferred via `ExposeScopeResolver.IsMoreSpecificCandidate`, +with the transition-count tie-break used only to break ties among equally specific candidates; +this ordering does not apply when `scope` is `null`. `CollectStates` gathers the declared `state` +usages first (preserving declaration order so the first declared state becomes the initial +state), excluding — when a scope is resolved — any declared state feature whose qualified name +fails `ExposeScopeResolver.IsInSubjectScope`; it then adds any additional state named only by a transition endpoint **unconditionally** (this second pass has no independent qualified name of its own to scope against, since it exists solely because a transition names it), building a name → index lookup. @@ -79,16 +83,20 @@ is selected** and then narrows **which of that root's states are shown**, mirror `ExposeScopeResolver.IsRootRelevantToScope` accepts, so exposing the current heuristic root itself, an inner state of it, or a definition that itself contains the heuristic default all correctly select a root, while exposing an unrelated definition yields no root and thus the -minimal empty canvas. `CollectStates` then narrows the selected root's own **declared** state -features to those within the resolved scope; however, any declared-but-excluded state that is -still referenced by an in-scope transition is transparently re-added by the unconditional -transition-endpoint pass, since that pass has no independent qualified name to filter against — so -expose-scoping only reliably drops a state that is genuinely isolated (never referenced by any -transition of the selected root). `ResolveTransitions`'s existing name-lookup approach naturally -omits any transition whose endpoint state was never added — no new edge-side logic was required. -A view with no `expose` statement (including the synthesized `--auto` view, whose `ViewNode` is -`null`) resolves no scope, so `FindRoot` considers every candidate and `CollectStates` keeps every -state, unchanged from the pre-scoping behavior. +minimal empty canvas. When more than one candidate is relevant (a nested definition and an +ancestor definition can both be relevant to the same exposed subject), +`ExposeScopeResolver.IsMoreSpecificCandidate` prefers the most deeply nested candidate, so +exposing an inner state of a nested definition correctly selects that nested definition rather +than its ancestor, even when the ancestor has more transitions. `CollectStates` then narrows the +selected root's own **declared** state features to those within the resolved scope; however, any +declared-but-excluded state that is still referenced by an in-scope transition is transparently +re-added by the unconditional transition-endpoint pass, since that pass has no independent +qualified name to filter against — so expose-scoping only reliably drops a state that is genuinely +isolated (never referenced by any transition of the selected root). `ResolveTransitions`'s +existing name-lookup approach naturally omits any transition whose endpoint state was never added +— no new edge-side logic was required. A view with no `expose` statement (including the +synthesized `--auto` view, whose `ViewNode` is `null`) resolves no scope, so `FindRoot` considers +every candidate and `CollectStates` keeps every state, unchanged from the pre-scoping behavior. ##### Error Handling diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml index d0344604..0cdacdec 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml @@ -112,3 +112,18 @@ sections: action rendering must remain the default in both cases. tests: - ActionFlowView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged + + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-ScopedSpecificityTieBreak + title: >- + When more than one candidate root is relevant to the resolved expose scope, + ActionFlowViewLayoutStrategy shall select the most specific (deepest-qualified-name) + relevant candidate, using its succession/action score only to break ties among equally + specific candidates. + justification: | + Because SysML v2 definitions may nest, an ancestor definition and a nested descendant + definition can both be relevant to the same resolved `expose` scope. Without a + specificity-first tie-break, the ancestor could win by raw succession/action score even + though the user's `expose` statement was scoped to the nested descendant, silently + defeating the intended scoping. + tests: + - ActionFlowView_BuildLayout_ExposeInnerActionOfNestedDefinition_SelectsNestedDefinitionNotAncestor diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml index b34c5648..5225d9fa 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml @@ -78,3 +78,25 @@ sections: - IsRootRelevantToScope_CandidateNestedInSubject_ReturnsTrue - IsRootRelevantToScope_SubjectNestedInCandidate_ReturnsTrue - IsRootRelevantToScope_UnrelatedCandidate_ReturnsFalse + + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-SpecificityTieBreak + title: >- + ExposeScopeResolver.IsMoreSpecificCandidate shall prefer the candidate with the longer + (more deeply nested) qualified name over one with a shorter qualified name regardless + of score, and shall fall back to the caller-supplied score comparison only when the two + qualified names are equal in length. + justification: | + Because SysML v2 definitions may nest, an ancestor definition and one of its nested + descendant definitions can both pass IsRootRelevantToScope for the same resolved + `expose` scope. Qualified-name length is a safe, cheap proxy for containment depth (a + descendant's qualified name is always strictly longer than its ancestors'), so + preferring the longer qualified name lets the four single-root strategies correctly + select the more specific (deeper) scope-relevant candidate the user's `expose` + statement was meant to reach, instead of silently falling back to an ancestor with a + higher raw heuristic score. + tests: + - IsMoreSpecificCandidate_NoCurrentBest_ReturnsTrue + - IsMoreSpecificCandidate_LongerQualifiedName_ReturnsTrueRegardlessOfScore + - IsMoreSpecificCandidate_ShorterQualifiedName_ReturnsFalseRegardlessOfScore + - IsMoreSpecificCandidate_EqualLength_FallsBackToScore_True + - IsMoreSpecificCandidate_EqualLength_FallsBackToScore_False 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 046aafb9..57c8b654 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 @@ -151,3 +151,18 @@ sections: interior rendering must remain the default in both cases. tests: - InterconnectionView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-ScopedSpecificityTieBreak + title: >- + When more than one candidate root is relevant to the resolved expose scope, + InterconnectionViewLayoutStrategy shall select the most specific (deepest-qualified-name) + relevant candidate, using its connections/parts heuristic only to break ties among + equally specific candidates. + justification: | + Because SysML v2 definitions may nest, an ancestor definition and a nested descendant + definition can both be relevant to the same resolved `expose` scope. Without a + specificity-first tie-break, the ancestor could win by raw connections/parts score even + though the user's `expose` statement was scoped to the nested descendant, silently + defeating the intended scoping. + tests: + - InterconnectionView_BuildLayout_ExposeInnerPartOfNestedDefinition_SelectsNestedDefinitionNotAncestor diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml index ea9ec72a..915a705b 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml @@ -99,3 +99,18 @@ sections: lifeline rendering must remain the default in both cases. tests: - SequenceView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged + + - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-ScopedSpecificityTieBreak + title: >- + When more than one candidate root is relevant to the resolved expose scope, + SequenceViewLayoutStrategy shall select the most specific (deepest-qualified-name) + relevant candidate, using its message-count heuristic only to break ties among equally + specific candidates. + justification: | + Because SysML v2 definitions may nest, an ancestor definition and a nested descendant + definition can both be relevant to the same resolved `expose` scope. Without a + specificity-first tie-break, the ancestor could win by raw message-count score even + though the user's `expose` statement was scoped to the nested descendant, silently + defeating the intended scoping. + tests: + - SequenceView_BuildLayout_ExposeInnerLifelineOfNestedDefinition_SelectsNestedDefinitionNotAncestor diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml index eee02d98..52107d73 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml @@ -107,3 +107,18 @@ sections: state rendering must remain the default in both cases. tests: - StateTransitionView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged + + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-ScopedSpecificityTieBreak + title: >- + When more than one candidate root is relevant to the resolved expose scope, + StateTransitionViewLayoutStrategy shall select the most specific (deepest-qualified-name) + relevant candidate, using its transition-count heuristic only to break ties among + equally specific candidates. + justification: | + Because SysML v2 definitions may nest, an ancestor definition and a nested descendant + definition can both be relevant to the same resolved `expose` scope. Without a + specificity-first tie-break, the ancestor could win by raw transition-count score even + though the user's `expose` statement was scoped to the nested descendant, silently + defeating the intended scoping. + tests: + - StateTransitionView_BuildLayout_ExposeInnerStateOfNestedDefinition_SelectsNestedDefinitionNotAncestor diff --git a/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md index 465faa12..f9913aeb 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md @@ -41,6 +41,10 @@ configuration are required beyond a standard .NET SDK installation. succession. - A view whose resolved `Expose` edge names a feature usage (not a definition) still resolves to the usage's type as the root, via the shared usage-to-type fallback. +- A view whose resolved `Expose` edge names an inner action of a definition genuinely nested + inside another eligible root candidate selects the nested definition, not the ancestor, even + though the ancestor has a higher succession/action score and would win the old pure-score + tie-break. ##### Test Scenarios @@ -60,3 +64,4 @@ configuration are required beyond a standard .NET SDK installation. | `ActionFlowView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas` | Unrelated def, no root | | `ActionFlowView_BuildLayout_ExposeSingleAction_DropsOutOfScopeAction` | Isolated action dropped; referenced kept | | `ActionFlowView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | +| `ActionFlowView_BuildLayout_ExposeInnerActionOfNestedDefinition_SelectsNestedDefinitionNotAncestor` | Nested wins | diff --git a/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md b/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md index e3088b0e..32ec0cec 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md +++ b/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md @@ -3,9 +3,10 @@ ##### Verification Approach `ExposeScopeResolver` is verified through direct unit tests in `ExposeScopeResolverTests` that -call `ResolveExposedScope`, `IsInSubjectScope`, and `IsRootRelevantToScope` directly with -synthetic `SysmlWorkspace`/`SysmlViewNode` inputs and assert on the returned scope or boolean -result. No mocking is required; every method is a pure function over its parameters. +call `ResolveExposedScope`, `IsInSubjectScope`, `IsRootRelevantToScope`, and +`IsMoreSpecificCandidate` directly with synthetic `SysmlWorkspace`/`SysmlViewNode` inputs and +assert on the returned scope or boolean result. No mocking is required; every method is a pure +function over its parameters. ##### Test Environment @@ -31,6 +32,13 @@ configuration are required beyond a standard .NET SDK installation. - `IsRootRelevantToScope` returns `true` when the candidate is nested within a subject. - `IsRootRelevantToScope` returns `true` when a subject is nested within the candidate. - `IsRootRelevantToScope` returns `false` for an unrelated candidate. +- With no current best candidate, `IsMoreSpecificCandidate` returns `true` for any candidate. +- `IsMoreSpecificCandidate` returns `true` for a candidate with a longer qualified name than the + current best, regardless of score. +- `IsMoreSpecificCandidate` returns `false` for a candidate with a shorter qualified name than the + current best, regardless of score. +- `IsMoreSpecificCandidate` falls back to the caller-supplied score comparison when the candidate + and current best have equal-length qualified names. ##### Test Scenarios @@ -58,3 +66,13 @@ configuration are required beyond a standard .NET SDK installation. Subject nested within the candidate is relevant - `IsRootRelevantToScope_UnrelatedCandidate_ReturnsFalse`: Unrelated candidate is not relevant +- `IsMoreSpecificCandidate_NoCurrentBest_ReturnsTrue`: + No current best candidate: any candidate becomes the new best +- `IsMoreSpecificCandidate_LongerQualifiedName_ReturnsTrueRegardlessOfScore`: + Longer (more deeply nested) qualified name wins even with a worse score +- `IsMoreSpecificCandidate_ShorterQualifiedName_ReturnsFalseRegardlessOfScore`: + Shorter qualified name loses even with a better score +- `IsMoreSpecificCandidate_EqualLength_FallsBackToScore_True`: + Equal-length qualified names fall back to the score comparison — true case +- `IsMoreSpecificCandidate_EqualLength_FallsBackToScore_False`: + Equal-length qualified names fall back to the score comparison — false case 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 c0f639c2..4ed2f070 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 @@ -43,6 +43,9 @@ configuration are required beyond a standard .NET SDK installation. subtrees, keeping the connection between them. - A view whose resolved `Expose` edge names a feature usage (not a definition) still resolves to the usage's type as the root, via the shared usage-to-type fallback. +- A view whose resolved `Expose` edge names an inner part of a definition genuinely nested inside + another eligible root candidate selects the nested definition, not the ancestor, even though the + ancestor has more connections/parts and would win the old pure-score tie-break. ##### Test Scenarios @@ -63,3 +66,4 @@ configuration are required beyond a standard .NET SDK installation. | `InterconnectionView_BuildLayout_ExposeSinglePart_NarrowsToThatPart` | Single exposed part narrows the container | | `InterconnectionView_BuildLayout_ExposeMultipleParts_UnionsBothSubtrees` | Two exposed parts union both subtrees | | `InterconnectionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | +| `InterconnectionView_BuildLayout_ExposeInnerPartOfNestedDefinition_SelectsNestedDefinitionNotAncestor` | Nested wins | diff --git a/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md index 3345b510..00cee24f 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md @@ -38,6 +38,9 @@ configuration are required beyond a standard .NET SDK installation. message between them. - A view whose resolved `Expose` edge names a feature usage (not a definition) still resolves to the usage's type as the root, via the shared usage-to-type fallback. +- A view whose resolved `Expose` edge names an inner lifeline participant of a definition + genuinely nested inside another eligible root candidate selects the nested definition, not the + ancestor, even though the ancestor has more messages and would win the old pure-score tie-break. ##### Test Scenarios @@ -55,3 +58,4 @@ configuration are required beyond a standard .NET SDK installation. | `SequenceView_BuildLayout_ExposeSingleLifeline_NarrowsLifelines` | Single exposed lifeline narrows the diagram | | `SequenceView_BuildLayout_ExposeBothLifelines_UnionsSubtreesKeepsMessages` | Two lifelines union, keeping messages | | `SequenceView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | +| `SequenceView_BuildLayout_ExposeInnerLifelineOfNestedDefinition_SelectsNestedDefinitionNotAncestor` | Nested wins | diff --git a/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md index cde66d96..4393890d 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.md @@ -39,6 +39,9 @@ configuration are required beyond a standard .NET SDK installation. transition. - A view whose resolved `Expose` edge names a feature usage (not a definition) still resolves to the usage's type as the root, via the shared usage-to-type fallback. +- A view whose resolved `Expose` edge names an inner state of a definition genuinely nested + inside another eligible root candidate selects the nested definition, not the ancestor, even + though the ancestor has more transitions and would win the old pure-score tie-break. ##### Test Scenarios @@ -56,3 +59,4 @@ configuration are required beyond a standard .NET SDK installation. | `StateTransitionView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas` | Unrelated def | | `StateTransitionView_BuildLayout_ExposeSingleState_DropsIsolatedOutOfScopeState` | Isolated state dropped | | `StateTransitionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | +| `StateTransitionView_BuildLayout_ExposeInnerStateOfNestedDefinition_SelectsNestedDefinitionNotAncestor` | Nested | diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs index fd42ee04..c891aa5a 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs @@ -119,13 +119,18 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) /// Finds the definition with the most successions to use as the diagram root. When /// is non-null (the view's resolved expose containment-subtree /// scope), candidates are first restricted to those relevant to the scope via - /// before the succession/action-count - /// tie-break applies; when no candidate is scope-relevant, no root is chosen (an empty canvas - /// results). + /// ; because a nested definition and its + /// ancestor can both be scope-relevant, ties among relevant candidates are then broken by + /// specificity (deepest/longest qualified name wins) via + /// , with the succession/action score + /// used only to break ties between equally specific candidates. When no candidate is + /// scope-relevant, no root is chosen (an empty canvas results). When is + /// , selection is the plain succession/action-count heuristic, unchanged. /// private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace, IReadOnlyList? scope) { SysmlDefinitionNode? best = null; + string? bestQualifiedName = null; var bestScore = -1; foreach (var (qualifiedName, node) in workspace.Declarations) @@ -148,9 +153,15 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var successions = def.Children.OfType().Count(); var actions = def.Children.OfType().Count(f => f.FeatureKeyword == "action"); var score = (successions * 100) + actions; - if (score > bestScore && (successions > 0 || actions > 0)) + var scoreBetter = score > bestScore; + var isBetter = scope is not null + ? ExposeScopeResolver.IsMoreSpecificCandidate(qualifiedName, bestQualifiedName, scoreBetter) + : scoreBetter; + + if (isBetter && (successions > 0 || actions > 0)) { best = def; + bestQualifiedName = qualifiedName; bestScore = score; } } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs index 86294274..3a7490d7 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs @@ -95,6 +95,15 @@ public static bool IsInSubjectScope(string qualifiedName, IReadOnlyList /// containment subtree (the common "expose an inner state/action/part/lifeline of the root" /// case). /// + /// + /// This method identifies the *set* of scope-relevant candidates only — because SysML v2 + /// definitions may nest, an ancestor definition and one of its nested descendant definitions can + /// both be relevant to the same resolved scope (an exposed subject nested inside the descendant + /// is transitively nested inside the ancestor too). Callers with more than one relevant candidate + /// 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 root definition's qualified name. /// The resolved expose scope subject qualified names. /// @@ -106,4 +115,43 @@ public static bool IsRootRelevantToScope(string candidateQualifiedName, IReadOnl candidateQualifiedName == subject || candidateQualifiedName.StartsWith(subject + "::", StringComparison.Ordinal) || subject.StartsWith(candidateQualifiedName + "::", StringComparison.Ordinal)); + + /// + /// Decides, for the scoped case, whether should replace + /// the current best scope-relevant root candidate. Specificity (containment depth) is compared + /// first: because SysML v2 qualified names are built by parent::child concatenation, any + /// genuine descendant's qualified name is strictly longer than its ancestors', so a longer + /// qualified name is a safe, cheap proxy for "more deeply nested" and always wins over a shorter + /// one regardless of score. Each strategy's own score heuristic (transition/connection+part/ + /// succession+action/message count) is used only as a fallback to break ties between candidates + /// of equal qualified-name length (e.g. unrelated siblings), via + /// . + /// + /// The candidate root definition's qualified name. + /// + /// The current best candidate's qualified name, or when no candidate has + /// been selected yet. + /// + /// + /// Whether the candidate's own per-strategy score is better than the current best's, used only + /// when the two qualified names are equal in length. + /// + /// + /// when the candidate should become the new best root; otherwise + /// . + /// + public static bool IsMoreSpecificCandidate( + string candidateQualifiedName, + string? currentBestQualifiedName, + bool currentScoreIsBetter) + { + if (currentBestQualifiedName is null) + { + return true; + } + + return candidateQualifiedName.Length != currentBestQualifiedName.Length + ? candidateQualifiedName.Length > currentBestQualifiedName.Length + : currentScoreIsBetter; + } } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs index ee645424..033e9120 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs @@ -254,13 +254,18 @@ private static InteriorLayout LayOutInterior( /// scope), candidates are first restricted to those relevant to the scope via /// — the candidate itself is an exposed /// subject, lies within an exposed subject's subtree, or an exposed subject lies within the - /// candidate's own subtree — before the same connections/parts tie-break applies; when no - /// candidate is scope-relevant, no root is chosen (an empty canvas results, matching the - /// existing null-root path). + /// candidate's own subtree; because a nested definition and its ancestor can both be + /// scope-relevant, ties among relevant candidates are then broken by specificity (deepest/longest + /// qualified name wins) via , with the + /// connections/parts heuristic used only to break ties between equally specific candidates. When + /// no candidate is scope-relevant, no root is chosen (an empty canvas results, matching the + /// existing null-root path). When is , selection is + /// the plain connections/parts heuristic, unchanged. /// private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace, IReadOnlyList? scope) { SysmlDefinitionNode? best = null; + string? bestQualifiedName = null; var bestConnections = -1; var bestParts = -1; @@ -283,10 +288,15 @@ private static InteriorLayout LayOutInterior( var connections = def.Children.OfType().Count(); var partCount = def.Children.OfType().Count(f => f.FeatureKeyword == "part"); + var scoreBetter = connections > bestConnections || (connections == bestConnections && partCount > bestParts); + var isBetter = scope is not null + ? ExposeScopeResolver.IsMoreSpecificCandidate(qualifiedName, bestQualifiedName, scoreBetter) + : scoreBetter; - if (connections > bestConnections || (connections == bestConnections && partCount > bestParts)) + if (isBetter) { best = def; + bestQualifiedName = qualifiedName; bestConnections = connections; bestParts = partCount; } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs index d41c5050..d8a40abe 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/SequenceViewLayoutStrategy.cs @@ -112,12 +112,18 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) /// Finds the definition with the most messages to use as the diagram root. When /// is non-null (the view's resolved expose containment-subtree /// scope), candidates are first restricted to those relevant to the scope via - /// before the message-count tie-break - /// applies; when no candidate is scope-relevant, no root is chosen (an empty canvas results). + /// ; because a nested definition and its + /// ancestor can both be scope-relevant, ties among relevant candidates are then broken by + /// specificity (deepest/longest qualified name wins) via + /// , with the message-count heuristic + /// used only to break ties between equally specific candidates. When no candidate is + /// scope-relevant, no root is chosen (an empty canvas results). When is + /// , selection is the plain message-count heuristic, unchanged. /// private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace, IReadOnlyList? scope) { SysmlDefinitionNode? best = null; + string? bestQualifiedName = null; var bestMessages = 0; foreach (var (qualifiedName, node) in workspace.Declarations) @@ -138,9 +144,15 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) } var messages = def.Children.OfType().Count(c => c.ConnectionKeyword == "message"); - if (messages > bestMessages) + var scoreBetter = messages > bestMessages; + var isBetter = scope is not null + ? ExposeScopeResolver.IsMoreSpecificCandidate(qualifiedName, bestQualifiedName, scoreBetter) + : scoreBetter; + + if (isBetter) { best = def; + bestQualifiedName = qualifiedName; bestMessages = messages; } } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs index 806b922c..d794f15e 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/StateTransitionViewLayoutStrategy.cs @@ -209,12 +209,18 @@ private static double LabelRightExtent(IReadOnlyList nodes, Theme th /// definition with the most children. When /// is non-null (the view's resolved expose containment-subtree /// scope), candidates are first restricted to those relevant to the scope via - /// before the transition-count tie-break - /// applies; when no candidate is scope-relevant, no root is chosen (an empty canvas results). + /// ; because a nested definition and its + /// ancestor can both be scope-relevant, ties among relevant candidates are then broken by + /// specificity (deepest/longest qualified name wins) via + /// , with the transition-count heuristic + /// used only to break ties between equally specific candidates. When no candidate is + /// scope-relevant, no root is chosen (an empty canvas results). When is + /// , selection is the plain transition-count heuristic, unchanged. /// private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace, IReadOnlyList? scope) { SysmlDefinitionNode? best = null; + string? bestQualifiedName = null; var bestTransitions = -1; foreach (var (qualifiedName, node) in workspace.Declarations) @@ -235,9 +241,15 @@ private static double LabelRightExtent(IReadOnlyList nodes, Theme th } var transitions = def.Children.OfType().Count(); - if (transitions > bestTransitions) + var scoreBetter = transitions > bestTransitions; + var isBetter = scope is not null + ? ExposeScopeResolver.IsMoreSpecificCandidate(qualifiedName, bestQualifiedName, scoreBetter) + : scoreBetter; + + if (isBetter) { best = def; + bestQualifiedName = qualifiedName; bestTransitions = transitions; } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs index 56cdf169..b36b6180 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs @@ -497,6 +497,81 @@ public void ActionFlowView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_Select Assert.Contains(boxes, b => b.Label == "b1"); } + /// + /// Builds a workspace where M::ProcessA (higher succession/action score) genuinely + /// nests M::ProcessA::ProcessC (lower score) as one of its own Children, while + /// both are also independently registered in Declarations under their own qualified + /// names — the shape needed to make both candidates scope-relevant for a subject exposed only + /// inside ProcessC. + /// + private static SysmlWorkspace BuildNestedCandidateWorkspace() + { + var processC = new SysmlDefinitionNode + { + Name = "ProcessC", + QualifiedName = "M::ProcessA::ProcessC", + DefinitionKeyword = "action def", + Children = + [ + new SysmlFeatureNode { Name = "c1", QualifiedName = "M::ProcessA::ProcessC::c1", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "c2", QualifiedName = "M::ProcessA::ProcessC::c2", FeatureKeyword = "action" }, + new SysmlTransitionNode { Source = "c1", Target = "c2" } + ] + }; + var processA = new SysmlDefinitionNode + { + Name = "ProcessA", + QualifiedName = "M::ProcessA", + DefinitionKeyword = "action def", + Children = + [ + new SysmlFeatureNode { Name = "a1", QualifiedName = "M::ProcessA::a1", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "a2", QualifiedName = "M::ProcessA::a2", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "a3", QualifiedName = "M::ProcessA::a3", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "a4", QualifiedName = "M::ProcessA::a4", FeatureKeyword = "action" }, + new SysmlTransitionNode { Source = "a1", Target = "a2" }, + new SysmlTransitionNode { Source = "a2", Target = "a3" }, + processC + ] + }; + return new SysmlWorkspace + { + Declarations = new Dictionary + { + ["M::ProcessA"] = processA, + ["M::ProcessA::ProcessC"] = processC + } + }; + } + + /// + /// Exposing an inner action of the nested definition ProcessC selects ProcessC + /// as the root even though its ancestor ProcessA has a higher succession/action score + /// and would win the old pure-score tie-break, proving FindRoot now prefers the most + /// specific (deepest-qualified-name) scope-relevant candidate over a less specific ancestor. + /// + [Fact] + public void ActionFlowView_BuildLayout_ExposeInnerActionOfNestedDefinition_SelectsNestedDefinitionNotAncestor() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var workspace = BuildNestedCandidateWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["c1"], + ResolvedEdges = [new SysmlEdge("M::V", "M::ProcessA::ProcessC::c1", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var boxes = layout.Nodes.OfType().Where(b => b.Keyword == "action").ToList(); + Assert.Contains(boxes, b => b.Label == "c1"); + Assert.DoesNotContain(boxes, b => b.Label is "a1" or "a2" or "a3" or "a4"); + } + /// /// An expose edge pointing at a definition unrelated to every candidate root makes no /// root scope-relevant, so no root is chosen and an empty canvas results. diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs index 9d34e5b5..0affbc0e 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs @@ -177,4 +177,51 @@ public void IsRootRelevantToScope_UnrelatedCandidate_ReturnsFalse() { Assert.False(ExposeScopeResolver.IsRootRelevantToScope("Root::B", ["Root::A"])); } + + /// With no current best candidate, any candidate becomes the new best. + [Fact] + public void IsMoreSpecificCandidate_NoCurrentBest_ReturnsTrue() + { + Assert.True(ExposeScopeResolver.IsMoreSpecificCandidate("Root::A", null, currentScoreIsBetter: false)); + } + + /// + /// A candidate with a longer (more deeply nested) qualified name always wins over the + /// current best, even when its own score is worse. + /// + [Fact] + public void IsMoreSpecificCandidate_LongerQualifiedName_ReturnsTrueRegardlessOfScore() + { + Assert.True(ExposeScopeResolver.IsMoreSpecificCandidate("Root::A::Child", "Root::A", currentScoreIsBetter: false)); + } + + /// + /// A candidate with a shorter qualified name than the current best always loses, even when + /// its own score is better. + /// + [Fact] + public void IsMoreSpecificCandidate_ShorterQualifiedName_ReturnsFalseRegardlessOfScore() + { + Assert.False(ExposeScopeResolver.IsMoreSpecificCandidate("Root::A", "Root::A::Child", currentScoreIsBetter: true)); + } + + /// + /// When the candidate and current best have equal-length qualified names (e.g. unrelated + /// siblings), the decision falls back to the caller-supplied score comparison — true case. + /// + [Fact] + public void IsMoreSpecificCandidate_EqualLength_FallsBackToScore_True() + { + Assert.True(ExposeScopeResolver.IsMoreSpecificCandidate("Root::SysB", "Root::SysA", currentScoreIsBetter: true)); + } + + /// + /// When the candidate and current best have equal-length qualified names (e.g. unrelated + /// siblings), the decision falls back to the caller-supplied score comparison — false case. + /// + [Fact] + public void IsMoreSpecificCandidate_EqualLength_FallsBackToScore_False() + { + Assert.False(ExposeScopeResolver.IsMoreSpecificCandidate("Root::SysB", "Root::SysA", currentScoreIsBetter: false)); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs index 1d3fd0a2..da43b8f9 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs @@ -370,6 +370,88 @@ private static SysmlWorkspace BuildNestedWorkspace() }; } + /// + /// Builds a workspace where M::SysA (more connections/parts) genuinely nests + /// M::SysA::SysC (fewer connections/parts) as one of its own Children, while + /// both are also independently registered in Declarations under their own qualified + /// names — the shape needed to make both candidates scope-relevant for a subject exposed + /// only inside SysC. + /// + private static SysmlWorkspace BuildNestedCandidateWorkspace() + { + var sysC = new SysmlDefinitionNode + { + Name = "SysC", + QualifiedName = "M::SysA::SysC", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "c1", QualifiedName = "M::SysA::SysC::c1", FeatureKeyword = "part", FeatureTyping = "C" }, + new SysmlFeatureNode { Name = "c2", QualifiedName = "M::SysA::SysC::c2", FeatureKeyword = "part", FeatureTyping = "C" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "c1", EndpointB = "c2" } + ] + }; + var sysA = new SysmlDefinitionNode + { + Name = "SysA", + QualifiedName = "M::SysA", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "a1", QualifiedName = "M::SysA::a1", FeatureKeyword = "part", FeatureTyping = "A" }, + new SysmlFeatureNode { Name = "a2", QualifiedName = "M::SysA::a2", FeatureKeyword = "part", FeatureTyping = "A" }, + new SysmlFeatureNode { Name = "a3", QualifiedName = "M::SysA::a3", FeatureKeyword = "part", FeatureTyping = "A" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "a1", EndpointB = "a2" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "a2", EndpointB = "a3" }, + sysC + ] + }; + return new SysmlWorkspace + { + Declarations = new Dictionary + { + ["M::SysA"] = sysA, + ["M::SysA::SysC"] = sysC + } + }; + } + + /// + /// Exposing an inner part of the nested definition SysC selects SysC as the + /// root even though its ancestor SysA has more connections/parts and would win the + /// old pure-score tie-break, proving FindRoot now prefers the most specific + /// (deepest-qualified-name) scope-relevant candidate over a less specific ancestor. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeInnerPartOfNestedDefinition_SelectsNestedDefinitionNotAncestor() + { + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = BuildNestedCandidateWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["c1"], + ResolvedEdges = [new SysmlEdge("M::V", "M::SysA::SysC::c1", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + Assert.Equal("SysC", container.Label); + + // Only c1 (SysC's part) is rendered, none of SysA's own parts (a1/a2/a3). + var partLabels = layout.Nodes.OfType() + .Where(b => b.Shape == BoxShape.RoundedRectangle) + .Select(b => b.Label) + .ToList(); + Assert.Contains(partLabels, l => l is not null && l.Contains("c1", StringComparison.Ordinal)); + Assert.DoesNotContain(partLabels, l => l is not null && + (l.Contains("a1", StringComparison.Ordinal) || l.Contains("a2", StringComparison.Ordinal) || l.Contains("a3", StringComparison.Ordinal))); + } + /// Finds the rounded part box with the given label across the whole layout tree. private static LayoutBox FindPartBox(LayoutTree layout, string label) { diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs index b1979921..8b5f29a2 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs @@ -310,6 +310,82 @@ public void SequenceView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsI Assert.Contains("x", lifelines); } + /// + /// Builds a workspace where M::ProtocolA (more messages) genuinely nests + /// M::ProtocolA::ProtocolC (fewer messages) as one of its own Children, while + /// both are also independently registered in Declarations under their own qualified + /// names — the shape needed to make both candidates scope-relevant for a subject exposed only + /// inside ProtocolC. + /// + private static SysmlWorkspace BuildNestedCandidateWorkspace() + { + var protocolC = new SysmlDefinitionNode + { + Name = "ProtocolC", + QualifiedName = "M::ProtocolA::ProtocolC", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "x", QualifiedName = "M::ProtocolA::ProtocolC::x", FeatureKeyword = "part" }, + new SysmlFeatureNode { Name = "y", QualifiedName = "M::ProtocolA::ProtocolC::y", FeatureKeyword = "part" }, + new SysmlConnectionNode { Name = "m", ConnectionKeyword = "message", EndpointA = "x.p", EndpointB = "y.q" }, + new SysmlConnectionNode { Name = "self", ConnectionKeyword = "message", EndpointA = "x.s", EndpointB = "x.t" } + ] + }; + var protocolA = new SysmlDefinitionNode + { + Name = "ProtocolA", + QualifiedName = "M::ProtocolA", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "client", QualifiedName = "M::ProtocolA::client", FeatureKeyword = "part" }, + new SysmlFeatureNode { Name = "server", QualifiedName = "M::ProtocolA::server", FeatureKeyword = "part" }, + new SysmlConnectionNode { Name = "req", ConnectionKeyword = "message", EndpointA = "client.a", EndpointB = "server.b" }, + new SysmlConnectionNode { Name = "resp", ConnectionKeyword = "message", EndpointA = "server.c", EndpointB = "client.d" }, + new SysmlConnectionNode { Name = "self", ConnectionKeyword = "message", EndpointA = "server.e", EndpointB = "server.f" }, + protocolC + ] + }; + return new SysmlWorkspace + { + Declarations = new Dictionary + { + ["M::ProtocolA"] = protocolA, + ["M::ProtocolA::ProtocolC"] = protocolC + } + }; + } + + /// + /// Exposing an inner lifeline participant of the nested definition ProtocolC selects + /// ProtocolC as the root even though its ancestor ProtocolA has more messages + /// and would win the old pure-score tie-break, proving FindRoot now prefers the most + /// specific (deepest-qualified-name) scope-relevant candidate over a less specific ancestor. + /// + [Fact] + public void SequenceView_BuildLayout_ExposeInnerLifelineOfNestedDefinition_SelectsNestedDefinitionNotAncestor() + { + var strategy = new SequenceViewLayoutStrategy(); + var workspace = BuildNestedCandidateWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["x"], + ResolvedEdges = [new SysmlEdge("M::V", "M::ProtocolA::ProtocolC::x", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var lifelines = layout.Nodes.OfType().Select(l => l.Label).ToList(); + Assert.Contains("x", lifelines); + Assert.DoesNotContain("client", lifelines); + Assert.DoesNotContain("server", lifelines); + } + /// /// An expose edge pointing at a definition unrelated to every candidate root makes no /// root scope-relevant, so no root is chosen and an empty canvas results. diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs index 498e898a..ccfeeb76 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs @@ -353,6 +353,80 @@ public void StateTransitionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_S Assert.Contains(boxes, b => b.Label == "b1"); } + /// + /// Builds a workspace where SM::MachineA (more transitions) genuinely nests + /// SM::MachineA::MachineB (fewer transitions) as one of its own Children, while + /// both are also independently registered in Declarations under their own qualified + /// names — the shape needed to make both candidates scope-relevant for a subject exposed only + /// inside MachineB. + /// + private static SysmlWorkspace BuildNestedCandidateWorkspace() + { + var machineB = new SysmlDefinitionNode + { + Name = "MachineB", + QualifiedName = "SM::MachineA::MachineB", + DefinitionKeyword = "state def", + Children = + [ + new SysmlFeatureNode { Name = "b1", QualifiedName = "SM::MachineA::MachineB::b1", FeatureKeyword = "state" }, + new SysmlFeatureNode { Name = "b2", QualifiedName = "SM::MachineA::MachineB::b2", FeatureKeyword = "state" }, + new SysmlTransitionNode { Source = "b1", Target = "b2", Guard = "g" } + ] + }; + var machineA = new SysmlDefinitionNode + { + Name = "MachineA", + QualifiedName = "SM::MachineA", + DefinitionKeyword = "state def", + Children = + [ + new SysmlFeatureNode { Name = "s1", QualifiedName = "SM::MachineA::s1", FeatureKeyword = "state" }, + new SysmlFeatureNode { Name = "s2", QualifiedName = "SM::MachineA::s2", FeatureKeyword = "state" }, + new SysmlFeatureNode { Name = "s3", QualifiedName = "SM::MachineA::s3", FeatureKeyword = "state" }, + new SysmlTransitionNode { Source = "s1", Target = "s2", Guard = "g1" }, + new SysmlTransitionNode { Source = "s2", Target = "s1", Guard = "g2" }, + machineB + ] + }; + return new SysmlWorkspace + { + Declarations = new Dictionary + { + ["SM::MachineA"] = machineA, + ["SM::MachineA::MachineB"] = machineB + } + }; + } + + /// + /// Exposing an inner state of the nested definition MachineB selects MachineB + /// as the root even though its ancestor MachineA has more transitions and would win the + /// old pure-score tie-break, proving FindRoot now prefers the most specific + /// (deepest-qualified-name) scope-relevant candidate over a less specific ancestor. + /// + [Fact] + public void StateTransitionView_BuildLayout_ExposeInnerStateOfNestedDefinition_SelectsNestedDefinitionNotAncestor() + { + var strategy = new StateTransitionViewLayoutStrategy(); + var workspace = BuildNestedCandidateWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "SM::V", + ExposedNames = ["b1"], + ResolvedEdges = [new SysmlEdge("SM::V", "SM::MachineA::MachineB::b1", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var boxes = layout.Nodes.OfType().Where(b => b.Keyword == "state").ToList(); + Assert.Contains(boxes, b => b.Label == "b1"); + Assert.DoesNotContain(boxes, b => b.Label is "s1" or "s2" or "s3"); + } + /// /// An expose edge pointing at a definition unrelated to every candidate root makes no /// root scope-relevant, so no root is chosen and an empty canvas results. From 9d6baac53001b90b9fc0df945231633e704709d9 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 6 Jul 2026 22:47:46 -0400 Subject: [PATCH 3/4] Address formal-review findings across GridView, specificity, requirements, docs - GridViewLayoutStrategy: implement the documented "at least one dimension in scope" relation-preserving rule. CollectDefinitions previously did a plain per-definition scope filter identical to General/Browser View, so a row/column related to an in-scope definition only via specialization (but itself outside the exposed subtree) was incorrectly dropped. Now a definition is retained when directly in scope, or when it participates in a specialization relationship (either direction) with an in-scope definition. - ExposeScopeResolver.IsMoreSpecificCandidate: replace raw qualified-name string-length comparison with containment-depth (":: " segment count) comparison. Length was an unsafe proxy for depth -- two same-depth sibling candidates with different name lengths could be misranked, wrongly favoring the longer name instead of deferring to the per-strategy score tie-break. Now only a genuine depth tie falls back to score. - Requirements atomicity: split the compound ExposeScoping-style requirement in each of the four single-root strategies' reqstream YAMLs (Interconnection, StateTransition, ActionFlow, Sequence) into separate atomic requirements for root selection, subtree/child filtering, and edge/endpoint retention. Reworded expose-scope-resolver and sequence-view-layout-strategy requirements to describe only observable behavior rather than internal comparison mechanics. browser-view-layout-strategy.yaml's pre-existing compound requirement is untouched (predates this branch). - Added regression tests: GridView specialization-pair retention, ExposeScopeResolver depth-vs-length sibling tie-break, a direct multi-target Expose-edge union test (definition + usage target) in ExposeScopeResolverTests, and a dangling-message assertion in SequenceViewLayoutStrategyTests' lifeline-narrowing test. - Documentation staleness fixes: updated rendering.md, sysml2-tools-core.md, verification/sysml2-tools-core.md, and layout.md to reflect all seven strategies (not just General View) honoring expose scoping via the shared ExposeScopeResolver; added a subsystem-level requirement/scenario to layout/internal.yaml and layout/internal.md; added the missing expose-scope-resolver.yaml reference to the root requirements.yaml decomposition tree; corrected expose-scope-resolver.md's caller-method count; converted general-view-layout-strategy.md's markdown hyperlink to a verbal cross-reference per convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/sysml2-tools-core.md | 14 ++-- docs/design/sysml2-tools-core/layout.md | 3 +- .../layout/internal/expose-scope-resolver.md | 17 ++-- .../internal/general-view-layout-strategy.md | 13 ++- .../internal/grid-view-layout-strategy.md | 47 +++++++---- docs/design/sysml2-tools-core/rendering.md | 22 ++++-- .../sysml2-tools-core/layout/internal.yaml | 30 +++++++ .../action-flow-view-layout-strategy.yaml | 43 +++++++--- .../internal/expose-scope-resolver.yaml | 25 +++--- .../internal/grid-view-layout-strategy.yaml | 15 +++- .../interconnection-view-layout-strategy.yaml | 39 +++++++-- .../sequence-view-layout-strategy.yaml | 54 +++++++++---- ...state-transition-view-layout-strategy.yaml | 43 +++++++--- docs/verification/sysml2-tools-core.md | 9 ++- .../sysml2-tools-core/layout/internal.md | 5 ++ .../layout/internal/expose-scope-resolver.md | 13 ++- .../internal/grid-view-layout-strategy.md | 5 ++ .../interconnection-view-layout-strategy.md | 5 ++ .../internal/sequence-view-layout-strategy.md | 10 ++- requirements.yaml | 1 + .../Layout/Internal/ExposeScopeResolver.cs | 29 ++++--- .../Layout/Internal/GridViewLayoutStrategy.cs | 79 +++++++++++++++++-- .../BrowserAndGridViewLayoutStrategyTests.cs | 44 +++++++++++ .../Layout/ExposeScopeResolverTests.cs | 65 +++++++++++++++ .../InterconnectionViewLayoutStrategyTests.cs | 76 ++++++++++++++++++ .../Layout/SequenceViewLayoutStrategyTests.cs | 7 ++ 26 files changed, 589 insertions(+), 124 deletions(-) diff --git a/docs/design/sysml2-tools-core.md b/docs/design/sysml2-tools-core.md index bb2a8a69..279c4226 100644 --- a/docs/design/sysml2-tools-core.md +++ b/docs/design/sysml2-tools-core.md @@ -101,11 +101,15 @@ N/A — not a safety-classified software item. `RenderOptions`. For each view declared in the workspace it constructs a `ViewContext` containing the view name, workspace reference, and (when available) the view's resolved AST node. -2. `ILayoutStrategy.BuildLayout` is called with the `ViewContext` and `RenderOptions`. The - Layout subsystem produces a fully resolved `LayoutTree` by delegating geometric placement and - routing to the off-the-shelf `DemaConsulting.Rendering.Layout` layered algorithm (through the - `LayeredPlacement` helper), placing every node at absolute coordinates and routing every - connector as an orthogonal polyline. The Rendering subsystem then renders that tree. +2. `ILayoutStrategy.BuildLayout` is called with the `ViewContext` and `RenderOptions`. Before + building its placement input, every strategy resolves the view's `expose` scope via the shared + `ExposeScopeResolver` helper (`ResolveExposedScope`/`IsInSubjectScope`/`IsRootRelevantToScope`/ + `IsMoreSpecificCandidate`), which scopes the returned `LayoutTree`'s content and, for the + single-root strategies, restricts root selection as well. The Layout subsystem then produces a + fully resolved `LayoutTree` by delegating geometric placement and routing to the off-the-shelf + `DemaConsulting.Rendering.Layout` layered algorithm (through the `LayeredPlacement` helper), + placing every node at absolute coordinates and routing every connector as an orthogonal + polyline. The Rendering subsystem then renders that tree. 3. `IRenderer.Render` is called with the `LayoutTree`, `RenderOptions`, and a fresh output `Stream`. The renderer reads each `LayoutNode` in the tree, translates it to output-format primitives, and writes bytes to the stream. diff --git a/docs/design/sysml2-tools-core/layout.md b/docs/design/sysml2-tools-core/layout.md index 31ae32fd..56bd43d2 100644 --- a/docs/design/sysml2-tools-core/layout.md +++ b/docs/design/sysml2-tools-core/layout.md @@ -191,7 +191,8 @@ chapter: - **Internal** — the per-view layout strategies that map the semantic model to a `LayoutTree` (general, interconnection, state transition, action flow, sequence, grid, and - browser views), plus `LayoutWarnings`. See the *Layout Internal Subsystem* chapter. + browser views), plus `LayoutWarnings`, plus the shared `ExposeScopeResolver` helper. See the + *Layout Internal Subsystem* chapter. - **LayeredPlacement** — a thin helper that adapts the off-the-shelf `DemaConsulting.Rendering.Layout` layered algorithm, returning placed rectangles and routed polylines to the strategies. See its own unit chapter. diff --git a/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md b/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md index 48a54d4a..e0763373 100644 --- a/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md +++ b/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md @@ -59,12 +59,13 @@ scope. Disambiguating among multiple relevant candidates is delegated to Decides, for the scoped case, whether `candidateQualifiedName` should replace the current best scope-relevant root candidate (`currentBestQualifiedName`, or `null` when no candidate has been -selected yet). Specificity (containment depth) is compared first: because SysML v2 qualified names -are built by `parent::child` concatenation, any genuine descendant's qualified name is strictly -longer than its ancestors', so a longer qualified name is a safe, cheap proxy for "more deeply -nested" and always wins over a shorter one regardless of score. Each strategy's own score heuristic +selected yet). Specificity (containment depth) is compared first, via a private `CountSegments` +helper that counts the `"::"`-separated segments of a qualified name (a bare simple name with no +`"::"` separator has depth 1, not 0): because SysML v2 qualified names are built by `parent::child` +concatenation, any genuine descendant has strictly more segments than its ancestors, so the deeper +candidate always wins over a shallower one regardless of score. Each strategy's own score heuristic (transition/connection+part/succession+action/message count) is used only as a fallback to break -ties between candidates of equal qualified-name length (e.g. unrelated siblings), via +ties between candidates of equal containment depth (e.g. unrelated siblings), via `currentScoreIsBetter`. Used only by the four single-root strategies, in place of a plain score comparison, whenever `scope` is non-null. @@ -86,8 +87,10 @@ non-existent `subjects`/workspace-declaration lookup are all treated as ordinary ##### Callers -- `GeneralViewLayoutStrategy` calls all three scope-membership methods (moved here verbatim from - its own former private copies of `ResolveExposedScope` and `IsInSubjectScope`). +- `GeneralViewLayoutStrategy` calls two of the four methods — `ResolveExposedScope` and + `IsInSubjectScope` (moved here verbatim from its own former private copies) — to filter its + workspace-wide model-edge collection directly; it has no single-root heuristic, so + `IsRootRelevantToScope`/`IsMoreSpecificCandidate` do not apply to it. - `GridViewLayoutStrategy` and `BrowserViewLayoutStrategy` call `ResolveExposedScope` and `IsInSubjectScope` to filter their workspace-wide definition/tree-node collections directly (no single-root heuristic, so `IsRootRelevantToScope`/`IsMoreSpecificCandidate` do not apply). diff --git a/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md index 1b14989f..96b1146c 100644 --- a/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md @@ -35,13 +35,12 @@ from the structural relationships. ###### `BuildLayout(ViewContext context, RenderOptions options)` Entry point. First resolves the view's exposed-name scope via the shared -`ExposeScopeResolver.ResolveExposedScope` (see -[ExposeScopeResolver](expose-scope-resolver.md)), then calls `CollectDefinitions` to gather user -definitions restricted to that scope (or every definition when no scope applies); returns a -minimal 200×100 empty `LayoutTree` when none are found. Otherwise groups the definitions by -package with `GroupByPackage`, resolves the specialization/membership/attribute-typing -relationships into qualified-name edges with `BuildModelEdges`, builds the single input -`LayoutGraph` with `BuildGraph`, and places the whole graph with one +`ExposeScopeResolver.ResolveExposedScope` (see the *ExposeScopeResolver* unit chapter), then calls +`CollectDefinitions` to gather user definitions restricted to that scope (or every definition when +no scope applies); returns a minimal 200×100 empty `LayoutTree` when none are found. Otherwise +groups the definitions by package with `GroupByPackage`, resolves the specialization/membership/ +attribute-typing relationships into qualified-name edges with `BuildModelEdges`, builds the single +input `LayoutGraph` with `BuildGraph`, and places the whole graph with one `HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("containment"))` call — passing the desired root-scope leaf algorithm through the options parameter (not `graph.Set(CoreOptions.Algorithm, …)`) so a caller going through `LayoutEngine.Layout(graph)` later diff --git a/docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md index 59285fd1..5518437b 100644 --- a/docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md +++ b/docs/design/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md @@ -11,8 +11,8 @@ definitions and their supertype references into a positioned `LayoutTree`. The strategy is a stateless `ILayoutStrategy`. Inputs are a `ViewContext` (carrying the `SysmlWorkspace`) and `RenderOptions` (carrying the `Theme`). It uses a private `DefRow` record -holding a definition's name and its supertype references. Output is a `LayoutTree` containing a -single `LayoutGrid` of `LayoutGridRow` and `LayoutGridCell` values. +holding a definition's qualified name, its simple name, and its supertype references. Output is a +`LayoutTree` containing a single `LayoutGrid` of `LayoutGridRow` and `LayoutGridCell` values. ##### Key Methods @@ -23,10 +23,9 @@ Builds the matrix: 1. **Scope resolution.** `ExposeScopeResolver.ResolveExposedScope` resolves the view's `expose` scope once (or `null` when none applies). 2. **Definition collection.** `CollectDefinitions` gathers the non-stdlib definitions in - deterministic (ordinal qualified-name) order, additionally excluding — when a scope was - resolved — any definition whose qualified name is not within it per - `ExposeScopeResolver.IsInSubjectScope`. An index map from simple name to column is built from - the (possibly narrowed) set. + deterministic (ordinal qualified-name) order, narrowed by the relation-preserving rule + described in *Expose Scoping* below when a scope was resolved. An index map from simple name to + column is built from the (possibly narrowed) set. 3. **Sizing.** Row height derives from the body font size and label padding; the header column width and the data column width derive from `MaxLabelWidth`, the widest definition label. 4. **Header row.** An empty corner cell is followed by one centered header cell per definition. @@ -42,14 +41,34 @@ scoping excludes every definition), a minimal empty `LayoutTree` with no nodes i ##### Expose Scoping -`CollectDefinitions` is the only place scoping applies: it is a direct, workspace-wide filter with -no single-root heuristic to restrict, so a resolved `expose` scope simply narrows the matrix to the -definitions within the exposed targets' containment subtrees (plus, via -`ExposeScopeResolver.ResolveExposedScope`'s usage-to-type fallback, an exposed feature usage's own -type). Multiple `expose` targets union their subtrees, since `IsInSubjectScope` matches against -every resolved subject. A view with no `expose` statement (including the synthesized `--auto` -view, whose `ViewNode` is `null`) resolves no scope and renders every non-stdlib definition, -unchanged from the pre-scoping behavior. +`CollectDefinitions` is the only place scoping applies. Unlike the four single-root strategies, the +Grid View has no root to restrict, so the resolved `expose` scope is applied directly as a +workspace-wide definition filter — but because the matrix's entire purpose is to show +specialization relationships between rows and columns, membership is decided along **two +dimensions**, and a definition is kept when **at least one** of them is in scope: + +1. **Direct containment** — the definition's own qualified name is within a resolved `expose` + target's containment subtree (`ExposeScopeResolver.IsInSubjectScope`), including, via + `ExposeScopeResolver.ResolveExposedScope`'s usage-to-type fallback, an exposed feature usage's + own type. Multiple `expose` targets union their subtrees, since `IsInSubjectScope` matches + against every resolved subject. +2. **Specialization relationship** — the definition is a supertype of an in-scope definition, or + an in-scope definition is one of its own supertypes (resolved by the same simple-name matching + `ResolveSupertypeIndices` uses for the marked cells). + +This relation-preserving rule means exposing only the specific side of a specialization (e.g. a +`Sub` that specializes an out-of-subtree `A`) still renders both `A` and `Sub` as header rows and +columns with the specialization mark between them, rather than silently dropping one side of the +relationship the matrix exists to show. + +Implementation-wise, `CollectDefinitions` runs in two phases so the `scope is null` case (no +`expose` statement, including the synthesized `--auto` view whose `ViewNode` is `null`) remains a +byte-identical "return everything" fast path: it first collects every non-stdlib definition +unfiltered and builds a full simple-name index across all of them, then — only when a scope is +resolved — computes which definitions are directly in scope, resolves every definition's +supertype indices against the full index, and keeps the union of the directly-in-scope set with +any definition connected to it by a specialization edge in either direction, before returning the +kept definitions in their original deterministic order. ##### Error Handling diff --git a/docs/design/sysml2-tools-core/rendering.md b/docs/design/sysml2-tools-core/rendering.md index 72ded57b..3787a054 100644 --- a/docs/design/sysml2-tools-core/rendering.md +++ b/docs/design/sysml2-tools-core/rendering.md @@ -137,14 +137,20 @@ flowchart TD 2. `ILayoutStrategy.BuildLayout` receives a `ViewContext` containing the workspace, the view name, and (when available) the view's resolved AST node, plus `RenderOptions` for size and scale hints. It produces a fully resolved `LayoutTree` with all waypoints in absolute canvas - coordinates. `GeneralViewLayoutStrategy` is the only strategy that currently reads - `ViewContext.ViewNode` to scope its diagram: when the view has one or more resolved `Expose` - edges, the diagram is scoped to the union of the exposed targets' containment subtrees - (resolving through a usage's type to its definition's subtree where needed); a view with no - `Expose` edges renders the full workspace, unchanged from prior behavior. The view's - `render`/`filter` statements never affect this scope. Every other strategy ignores - `ViewContext.ViewNode` and renders as before (see the Layout subsystem's - `general-view-layout-strategy` design doc for the scoping algorithm). + coordinates. All seven layout strategies (`GeneralViewLayoutStrategy`, + `GridViewLayoutStrategy`, `BrowserViewLayoutStrategy`, `InterconnectionViewLayoutStrategy`, + `StateTransitionViewLayoutStrategy`, `ActionFlowViewLayoutStrategy`, and + `SequenceViewLayoutStrategy`) now read `ViewContext.ViewNode` to scope their diagrams, via the + shared `ExposeScopeResolver` helper: when the view has one or more resolved `Expose` edges, + each strategy's diagram is scoped to the union of the exposed targets' containment subtrees + (resolving through a usage's type to its definition's subtree where needed). `GeneralView`, + `GridView`, and `BrowserView` apply this scope directly as a workspace-wide filter; the four + single-root strategies (`InterconnectionView`, `StateTransitionView`, `ActionFlowView`, and + `SequenceView`) additionally use the resolved scope to restrict which single root each one + selects before narrowing that root's own content. A view with no `Expose` edges renders the + full workspace (or full root content), unchanged from prior behavior. The view's + `render`/`filter` statements never affect this scope (see the Layout subsystem's + *ExposeScopeResolver* unit chapter for the shared scoping algorithm). 3. `IRenderer.Render` receives the `LayoutTree` and `RenderOptions` and writes all rendered bytes to the supplied `Stream`. It must not perform any layout computation; it only reads diff --git a/docs/reqstream/sysml2-tools-core/layout/internal.yaml b/docs/reqstream/sysml2-tools-core/layout/internal.yaml index 65c63cfa..1432dcae 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal.yaml @@ -64,6 +64,36 @@ sections: tests: - InterconnectionView_BuildLayout_PartBoxes_DoNotOverlap + - id: SysML2Tools-Core-Layout-Internal-SharedExposeScoping + title: >- + Each view layout strategy shall honor a view's resolved expose scope via the shared + ExposeScopeResolver helper, rendering every non-stdlib element (or the pre-scoping + heuristic root) unchanged when the view has no resolved Expose edge, and scoping to the + union of every exposed target's containment subtree (including any usage's resolved + type) when it does. + justification: | + Every view kind must apply `expose` scoping identically so a user's `expose` statement + behaves predictably regardless of which diagram kind renders it; sharing one resolver + across all seven strategies is what makes that consistency observable rather than + coincidental, including the critical "no expose statement renders everything unchanged" + fallback and the "multiple expose targets union" case. + tests: + - GeneralViewLayoutStrategy_BuildLayout_ExposedName_UnionsAdditionalSubtree + - GeneralViewLayoutStrategy_BuildLayout_RenderTargetNameOnly_NoExposeEdges_RendersFullWorkspace + - GridView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees + - GridView_BuildLayout_NullViewNode_RendersFullWorkspaceUnchanged + - BrowserView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees + - BrowserView_BuildLayout_NullViewNode_RendersFullWorkspaceUnchanged + - InterconnectionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot + - InterconnectionView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged + - StateTransitionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot + - StateTransitionView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged + - ActionFlowView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot + - ActionFlowView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged + - SequenceView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot + - SequenceView_BuildLayout_NullViewNode_PicksHeuristicRootUnchanged + - ResolveExposedScope_TwoExposeEdges_DefinitionAndUsageTarget_UnionsBothPlusResolvedType + - id: SysML2Tools-Core-Layout-Internal-LayoutWarnings title: >- When a view layout cannot avoid a layout-quality problem such as a connector diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml index 0cdacdec..e7ef696e 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml @@ -78,29 +78,50 @@ sections: - ActionFlowView_BuildLayout_BranchAndJoin - ActionFlowView_BuildLayout_Cycle_IsBroken - - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-ExposeScoping + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-ExposeScopingRootSelection title: >- When a view's ViewContext carries one or more resolved Expose edges, ActionFlowViewLayoutStrategy shall restrict its heuristic root selection to a candidate - relevant to the resolved scope and narrow the selected root's declared actions to those - within that scope, still including any excluded action re-referenced by an in-scope - succession. + relevant to the resolved scope. justification: | The Action Flow View renders exactly one selected root's actions, so `expose` scoping cannot narrow a workspace-wide collection directly; instead it restricts which root is - selected — accepting the current heuristic root itself, an inner action of it, or a - definition containing the heuristic default — and then narrows the selected root's own - declared actions to the resolved scope, while the existing succession-endpoint pass - (which has no independent qualified name to filter against) continues to reintroduce any - action still referenced by an in-scope succession, so only genuinely isolated - out-of-scope actions are dropped. + selected — accepting the current heuristic root itself, an inner action of it, a + definition containing the heuristic default, or the resolved type of an exposed usage — + and selecting no root at all when nothing in the workspace is relevant to the scope. tests: - ActionFlowView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot - ActionFlowView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot - ActionFlowView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas - - ActionFlowView_BuildLayout_ExposeSingleAction_DropsOutOfScopeAction - ActionFlowView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-ExposeScopingActionFiltering + title: >- + When a view's ViewContext carries one or more resolved Expose edges, + ActionFlowViewLayoutStrategy shall narrow the selected root's declared actions to those + within the resolved scope. + justification: | + Once a root is selected, `expose` scoping must also narrow what is drawn inside it — + otherwise every declared action of the selected root would still be rendered regardless + of which actions the view actually exposed, defeating the purpose of scoping the flow's + content. + tests: + - ActionFlowView_BuildLayout_ExposeSingleAction_DropsOutOfScopeAction + + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-ExposeScopingSuccessionEndpointRetention + title: >- + ActionFlowViewLayoutStrategy shall conservatively re-include any action excluded by + action filtering that is still referenced by an in-scope succession, so only genuinely + isolated out-of-scope actions are dropped. + justification: | + A succession whose endpoint action was excluded by filtering would otherwise point at + nothing; the existing succession-endpoint pass (which has no independent qualified name + to filter against) reintroduces any action still referenced by an in-scope succession, + keeping the flow's control edges readable while still dropping actions with no remaining + reference. + tests: + - ActionFlowView_BuildLayout_ExposeSingleAction_DropsOutOfScopeAction + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-NoExposeFallback title: >- When a view's ViewContext carries no resolved Expose edge — including a null ViewNode diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml index 5225d9fa..5c857722 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml @@ -29,9 +29,12 @@ sections: justification: | A usage's own containment subtree is typically empty — the real content lives under its type's subtree. Without this fallback, exposing a usage would silently scope the diagram - to nothing; following the usage's own type reference fixes this containment gap. + to nothing; following the usage's own type reference fixes this containment gap. This + fallback applies per-target, so a view with multiple `expose` edges gets the union of + every target's own resolved scope, whether that target is a definition or a usage. tests: - ResolveExposedScope_ExposedUsage_AlsoIncludesResolvedTypeTarget + - ResolveExposedScope_TwoExposeEdges_DefinitionAndUsageTarget_UnionsBothPlusResolvedType - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-NoScopeFallback title: >- @@ -81,22 +84,22 @@ sections: - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-SpecificityTieBreak title: >- - ExposeScopeResolver.IsMoreSpecificCandidate shall prefer the candidate with the longer - (more deeply nested) qualified name over one with a shorter qualified name regardless - of score, and shall fall back to the caller-supplied score comparison only when the two - qualified names are equal in length. + ExposeScopeResolver.IsMoreSpecificCandidate shall prefer the more deeply nested + candidate over a less deeply nested one regardless of score, and shall fall back to the + caller-supplied score comparison only when two candidates are equally deeply nested. justification: | Because SysML v2 definitions may nest, an ancestor definition and one of its nested descendant definitions can both pass IsRootRelevantToScope for the same resolved - `expose` scope. Qualified-name length is a safe, cheap proxy for containment depth (a - descendant's qualified name is always strictly longer than its ancestors'), so - preferring the longer qualified name lets the four single-root strategies correctly - select the more specific (deeper) scope-relevant candidate the user's `expose` - statement was meant to reach, instead of silently falling back to an ancestor with a - higher raw heuristic score. + `expose` scope. Preferring the more deeply nested candidate lets the four single-root + strategies correctly select the more specific (deeper) scope-relevant candidate the + user's `expose` statement was meant to reach, instead of silently falling back to an + ancestor with a higher raw heuristic score, while unrelated same-depth siblings (with no + containment relationship) still fall back to the caller's own score comparison. tests: - IsMoreSpecificCandidate_NoCurrentBest_ReturnsTrue - IsMoreSpecificCandidate_LongerQualifiedName_ReturnsTrueRegardlessOfScore - IsMoreSpecificCandidate_ShorterQualifiedName_ReturnsFalseRegardlessOfScore - IsMoreSpecificCandidate_EqualLength_FallsBackToScore_True - IsMoreSpecificCandidate_EqualLength_FallsBackToScore_False + - IsMoreSpecificCandidate_SameDepthSiblingsDifferentLength_ShorterWithBetterScoreWins + - IsMoreSpecificCandidate_SameDepthSiblingsDifferentLength_ShorterWithWorseScoreLoses diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml index 83aae062..f093fa31 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/grid-view-layout-strategy.yaml @@ -35,16 +35,25 @@ sections: title: >- When a view's ViewContext carries one or more resolved Expose edges, GridViewLayoutStrategy shall scope the matrix to the union of each edge's target - containment subtree, resolved through the target's own type reference when the target - is a usage rather than a definition. + containment subtree (resolved through the target's own type reference when the target is + a usage rather than a definition), while keeping any definition that participates in a + specialization relationship with an in-scope definition even when that definition is + itself outside every exposed containment subtree. justification: | The Grid View has no single-root heuristic to restrict, so it applies the shared `expose` scope directly as a workspace-wide definition filter, giving it the same - observable scoping behavior as every other view kind. + observable scoping behavior as every other view kind. Because the matrix exists to show + specialization relationships, a definition is relevant to the exposed scope along either + of two dimensions — direct containment, or participating in a specialization + relationship with an in-scope definition — so at least one dimension being in scope is + sufficient to keep a definition visible; otherwise exposing only one side of a + specialization would silently render a relationship mark against a missing row or + column. tests: - GridView_BuildLayout_ExposedName_UnionsAdditionalSubtree - GridView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees - GridView_BuildLayout_ExposedUsage_ResolvesThroughTypingToDefinitionSubtree + - GridView_BuildLayout_ExposeOneSideOfSpecialization_KeepsBothRowAndColumn - id: SysML2Tools-Core-Layout-Internal-GridViewLayoutStrategy-NoExposeFallback title: >- 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 57c8b654..eb9d3a29 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 @@ -119,26 +119,48 @@ sections: tests: - InterconnectionView_BuildLayout_SelfReferentialType_TreatedAsLeaf - - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-ExposeScoping + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-ExposeScopingRootSelection title: >- When a view's ViewContext carries one or more resolved Expose edges, InterconnectionViewLayoutStrategy shall restrict its heuristic root selection to a - candidate relevant to the resolved scope and narrow the selected root's nested parts to - those within that scope, dropping any connection whose endpoint was excluded. + candidate relevant to the resolved scope. justification: | The Interconnection View renders exactly one selected root's interior, so `expose` scoping cannot narrow a workspace-wide collection directly; instead it restricts which - root is selected — accepting the current heuristic root itself, an inner part of it, or - a definition containing the heuristic default — and then narrows the selected root's - own nested parts to the resolved scope, relying on the existing connection endpoint - lookup to drop any connection that references an excluded part. + root is selected — accepting the current heuristic root itself, an inner part of it, a + definition containing the heuristic default, or the resolved type of an exposed usage — + and selecting no root at all when nothing in the workspace is relevant to the scope. tests: - InterconnectionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot - InterconnectionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot - InterconnectionView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas + - InterconnectionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-ExposeScopingPartFiltering + title: >- + When a view's ViewContext carries one or more resolved Expose edges, + InterconnectionViewLayoutStrategy shall narrow the selected root's nested parts to those + within the resolved scope. + justification: | + Once a root is selected, `expose` scoping must also narrow what is drawn inside it — + otherwise every nested part of the selected root would still be rendered regardless of + which parts the view actually exposed, defeating the purpose of scoping the interior + content. + tests: + - InterconnectionView_BuildLayout_ExposeSinglePart_NarrowsToThatPart + - InterconnectionView_BuildLayout_ExposeMultipleParts_UnionsBothSubtrees + + - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-ExposeScopingConnectionRetention + title: >- + InterconnectionViewLayoutStrategy shall drop any connection whose endpoint was excluded + by part filtering, while keeping a connection whose endpoints are both still in scope. + justification: | + A connector line referencing a part that scoping has removed would point at nothing; + dropping such dangling connections keeps the diagram consistent, while a connection + between two parts that both remain in scope must still be drawn. + tests: - InterconnectionView_BuildLayout_ExposeSinglePart_NarrowsToThatPart - InterconnectionView_BuildLayout_ExposeMultipleParts_UnionsBothSubtrees - - InterconnectionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot - id: SysML2Tools-Core-Layout-Internal-InterconnectionViewLayoutStrategy-NoExposeFallback title: >- @@ -166,3 +188,4 @@ sections: defeating the intended scoping. tests: - InterconnectionView_BuildLayout_ExposeInnerPartOfNestedDefinition_SelectsNestedDefinitionNotAncestor + - InterconnectionView_BuildLayout_ExposeBothSameDepthSiblings_ScoreBreaksTieNotLength diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml index 915a705b..f2aa5797 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.yaml @@ -53,40 +53,60 @@ sections: - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-LifelineQualifiedNameReconstruction title: >- - SequenceViewLayoutStrategy shall reconstruct a candidate lifeline's absolute qualified - name as "{root.QualifiedName}::{lifelineName}" and this reconstruction shall match the - declared feature's own QualifiedName for a directly-nested part feature referenced by a - message endpoint. + SequenceViewLayoutStrategy shall filter lifelines by their own effective qualified name + so that `expose`-scoped lifeline filtering matches the declared feature's actual + qualified name. justification: | Filtering lifelines by expose scope requires each lifeline's own qualified name, which - is never stored directly on the lightweight lifeline record; this reconstruction was - confirmed reliable for realistic models — mirroring the real client-server-sequence.sysml - fixture — before being relied upon for scope filtering, avoiding a silently incorrect - filter. + is never stored directly on the lightweight lifeline record; using the lifeline's own + effective qualified name (rather than, for example, its bare local name) was confirmed + reliable for realistic models — mirroring the real client-server-sequence.sysml fixture + — before being relied upon for scope filtering, avoiding a silently incorrect filter. tests: - SequenceView_LifelineQualifiedNameReconstruction_MatchesDeclaredFeature - - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-ExposeScoping + - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-ExposeScopingRootSelection title: >- When a view's ViewContext carries one or more resolved Expose edges, SequenceViewLayoutStrategy shall restrict its heuristic root selection to a candidate - relevant to the resolved scope and narrow the selected root's lifelines to those within - that scope, dropping any message whose endpoint was excluded. + relevant to the resolved scope. justification: | The Sequence View renders exactly one selected root's lifelines, so `expose` scoping cannot narrow a workspace-wide collection directly; instead it restricts which root is - selected — accepting the current heuristic root itself, an inner lifeline of it, or a - definition containing the heuristic default — and then narrows the selected root's own - lifelines to the resolved scope using the reconstructed qualified name, relying on the - existing message endpoint lookup to drop any message that references an excluded - lifeline. + selected — accepting the current heuristic root itself, an inner lifeline of it, a + definition containing the heuristic default, or the resolved type of an exposed usage — + and selecting no root at all when nothing in the workspace is relevant to the scope. tests: - SequenceView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot - SequenceView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot - SequenceView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas + - SequenceView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot + + - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-ExposeScopingLifelineFiltering + title: >- + When a view's ViewContext carries one or more resolved Expose edges, + SequenceViewLayoutStrategy shall narrow the selected root's lifelines to those within the + resolved scope. + justification: | + Once a root is selected, `expose` scoping must also narrow what is drawn inside it — + otherwise every lifeline of the selected root would still be rendered regardless of which + lifelines the view actually exposed, defeating the purpose of scoping the interaction's + content. + tests: + - SequenceView_BuildLayout_ExposeSingleLifeline_NarrowsLifelines + - SequenceView_BuildLayout_ExposeBothLifelines_UnionsSubtreesKeepsMessages + + - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-ExposeScopingMessageDropping + title: >- + SequenceViewLayoutStrategy shall drop any message whose endpoint was excluded by + lifeline filtering, while keeping a message whose endpoints are both still in scope. + justification: | + A message line referencing a lifeline that scoping has removed would point at nothing; + dropping such dangling messages keeps the diagram consistent, while a message between two + lifelines that both remain in scope must still be drawn. + tests: - SequenceView_BuildLayout_ExposeSingleLifeline_NarrowsLifelines - SequenceView_BuildLayout_ExposeBothLifelines_UnionsSubtreesKeepsMessages - - SequenceView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot - id: SysML2Tools-Core-Layout-Internal-SequenceViewLayoutStrategy-NoExposeFallback title: >- diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml index 52107d73..ca0d6502 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml @@ -73,29 +73,50 @@ sections: tests: - StateTransitionView_BuildLayout_ForwardChain_FlowsTopToBottomOrthogonally - - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-ExposeScoping + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-ExposeScopingRootSelection title: >- When a view's ViewContext carries one or more resolved Expose edges, StateTransitionViewLayoutStrategy shall restrict its heuristic root selection to a - candidate relevant to the resolved scope and narrow the selected root's declared states - to those within that scope, still including any excluded state re-referenced by an - in-scope transition. + candidate relevant to the resolved scope. justification: | The State Transition View renders exactly one selected root's states, so `expose` scoping cannot narrow a workspace-wide collection directly; instead it restricts which - root is selected — accepting the current heuristic root itself, an inner state of it, or - a definition containing the heuristic default — and then narrows the selected root's own - declared states to the resolved scope, while the existing transition-endpoint pass - (which has no independent qualified name to filter against) continues to reintroduce any - state still referenced by an in-scope transition, so only genuinely isolated - out-of-scope states are dropped. + root is selected — accepting the current heuristic root itself, an inner state of it, a + definition containing the heuristic default, or the resolved type of an exposed usage — + and selecting no root at all when nothing in the workspace is relevant to the scope. tests: - StateTransitionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot - StateTransitionView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot - StateTransitionView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas - - StateTransitionView_BuildLayout_ExposeSingleState_DropsIsolatedOutOfScopeState - StateTransitionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-ExposeScopingStateFiltering + title: >- + When a view's ViewContext carries one or more resolved Expose edges, + StateTransitionViewLayoutStrategy shall narrow the selected root's declared states to + those within the resolved scope. + justification: | + Once a root is selected, `expose` scoping must also narrow what is drawn inside it — + otherwise every declared state of the selected root would still be rendered regardless + of which states the view actually exposed, defeating the purpose of scoping the state + machine's content. + tests: + - StateTransitionView_BuildLayout_ExposeSingleState_DropsIsolatedOutOfScopeState + + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-ExposeScopingTransitionEndpointRetention + title: >- + StateTransitionViewLayoutStrategy shall conservatively re-include any state excluded by + state filtering that is still referenced by an in-scope transition, so only genuinely + isolated out-of-scope states are dropped. + justification: | + A transition whose endpoint state was excluded by filtering would otherwise point at + nothing; the existing transition-endpoint pass (which has no independent qualified name + to filter against) reintroduces any state still referenced by an in-scope transition, + keeping the state machine's transitions readable while still dropping states with no + remaining reference. + tests: + - StateTransitionView_BuildLayout_ExposeSingleState_DropsIsolatedOutOfScopeState + - id: SysML2Tools-Core-Layout-Internal-StateTransitionViewLayoutStrategy-NoExposeFallback title: >- When a view's ViewContext carries no resolved Expose edge — including a null ViewNode diff --git a/docs/verification/sysml2-tools-core.md b/docs/verification/sysml2-tools-core.md index dc07fe12..4d89f72e 100644 --- a/docs/verification/sysml2-tools-core.md +++ b/docs/verification/sysml2-tools-core.md @@ -4,8 +4,10 @@ System-level verification for the `DemaConsulting.SysML2Tools` core library uses unit tests in `DemaConsulting.SysML2Tools.Tests`. Tests exercise the Layout and Rendering pipeline via -`DiagramRenderer` and `GeneralViewLayoutStrategy`. The xUnit v3 framework discovers and runs all -test methods; results are captured in TRX files consumed by ReqStream. +`DiagramRenderer` and `GeneralViewLayoutStrategy`, along with the shared +`ExposeScopeResolver`-based expose-scoping path exercised by all seven layout strategies. The +xUnit v3 framework discovers and runs all test methods; results are captured in TRX files +consumed by ReqStream. ## Test Environment @@ -18,6 +20,9 @@ SDK installation. - All unit tests pass with zero failures across all three target frameworks. - `DiagramRenderer.RenderWorkspace` correctly renders views declared in a `SysmlWorkspace`. - `GeneralViewLayoutStrategy` produces a valid `LayoutTree` for a given `ViewContext`. +- Every layout strategy honors a view's resolved `expose` scope via the shared + `ExposeScopeResolver` helper, including the no-`expose` fallback (rendering everything + unchanged) and a multi-target `expose` union. ## Test Scenarios diff --git a/docs/verification/sysml2-tools-core/layout/internal.md b/docs/verification/sysml2-tools-core/layout/internal.md index 262b265e..fedcfe00 100644 --- a/docs/verification/sysml2-tools-core/layout/internal.md +++ b/docs/verification/sysml2-tools-core/layout/internal.md @@ -22,6 +22,10 @@ configuration are required beyond a standard .NET SDK installation. - Boxes within a diagram do not overlap one another. - A layout-quality problem such as a connector crossing a box surfaces a non-fatal warning naming the affected view, while a clean layout produces no warning. +- Every strategy honors a view's resolved `expose` scope via the shared `ExposeScopeResolver` + helper: a view with no resolved `Expose` edge renders unchanged (the no-`expose` fallback), and + a view with multiple resolved `Expose` edges unions every target's scope (the multi-target + union case). #### Test Scenarios @@ -38,3 +42,4 @@ configuration are required beyond a standard .NET SDK installation. | Indented membership tree | `BrowserViewLayoutStrategy` | Nested elements indented beyond their parents | | Layout-quality warning | `LayoutWarnings` | Crossing connectors surface a view-named warning; none when clean | | Empty workspace | All strategies | A minimal empty canvas with no nodes | +| Shared expose scoping | All strategies, `ExposeScopeResolver` | No-expose fallback; multi-target union | diff --git a/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md b/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md index 32ec0cec..9e66ce37 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md +++ b/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md @@ -22,6 +22,8 @@ configuration are required beyond a standard .NET SDK installation. definition's qualified name. - A resolved `Expose` edge targeting a feature usage additionally includes that usage's own resolved `Typing` edge target qualified name in the scope. +- Two resolved `Expose` edges on the same view — one targeting a plain definition, one targeting a + feature usage — union both targets plus the usage's resolved type into the returned scope. - `IsInSubjectScope` returns `true` for an exact qualified-name match. - `IsInSubjectScope` returns `true` for a qualified name nested under a subject (a `"{subject}::"` prefix match). @@ -38,7 +40,8 @@ configuration are required beyond a standard .NET SDK installation. - `IsMoreSpecificCandidate` returns `false` for a candidate with a shorter qualified name than the current best, regardless of score. - `IsMoreSpecificCandidate` falls back to the caller-supplied score comparison when the candidate - and current best have equal-length qualified names. + and current best are equally deeply nested (whether their qualified names are equal in length or + merely equal in containment depth). ##### Test Scenarios @@ -50,6 +53,8 @@ configuration are required beyond a standard .NET SDK installation. Exposed definition resolves to a scope of exactly that qualified name - `ResolveExposedScope_ExposedUsage_AlsoIncludesResolvedTypeTarget`: Exposed usage resolves to a scope including both the usage and its resolved type +- `ResolveExposedScope_TwoExposeEdges_DefinitionAndUsageTarget_UnionsBothPlusResolvedType`: + Two `Expose` edges (a definition and a usage) union both targets plus the usage's resolved type - `IsInSubjectScope_ExactMatch_ReturnsTrue`: Exact qualified-name match is in scope - `IsInSubjectScope_SubtreeMatch_ReturnsTrue`: @@ -76,3 +81,9 @@ configuration are required beyond a standard .NET SDK installation. Equal-length qualified names fall back to the score comparison — true case - `IsMoreSpecificCandidate_EqualLength_FallsBackToScore_False`: Equal-length qualified names fall back to the score comparison — false case +- `IsMoreSpecificCandidate_SameDepthSiblingsDifferentLength_ShorterWithBetterScoreWins`: + Same-depth siblings with different qualified-name lengths fall back to score — shorter + candidate with a better score wins +- `IsMoreSpecificCandidate_SameDepthSiblingsDifferentLength_ShorterWithWorseScoreLoses`: + Same-depth siblings with different qualified-name lengths fall back to score — shorter + candidate with a worse score loses diff --git a/docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md index 588771e6..24c59345 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/grid-view-layout-strategy.md @@ -28,6 +28,10 @@ configuration are required beyond a standard .NET SDK installation. usage's type's containment subtree, via the shared usage-to-type fallback. - A view with an `expose` statement naming two separate definitions unions both their containment subtrees into the matrix. +- A view whose `expose` statement targets only the specific side of a specialization relationship + still keeps both the specific and general side visible as header rows/columns, with the + specialization mark between them, while an unrelated sibling definition remains excluded — the + "at least one dimension in scope" relation-preserving rule. ##### Test Scenarios @@ -39,3 +43,4 @@ configuration are required beyond a standard .NET SDK installation. | `GridView_BuildLayout_NullViewNode_RendersFullWorkspaceUnchanged` | Null `ViewNode` renders all defs unchanged | | `GridView_BuildLayout_ExposedUsage_ResolvesThroughTypingToDefinitionSubtree` | Usage resolves via `Typing` | | `GridView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees` | Two `expose` targets union both subtrees | +| `GridView_BuildLayout_ExposeOneSideOfSpecialization_KeepsBothRowAndColumn` | Keeps `A` and `Sub`, excludes `B` | 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 4ed2f070..a7d85208 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 @@ -46,6 +46,10 @@ configuration are required beyond a standard .NET SDK installation. - A view whose resolved `Expose` edge names an inner part of a definition genuinely nested inside another eligible root candidate selects the nested definition, not the ancestor, even though the ancestor has more connections/parts and would win the old pure-score tie-break. +- When two same-depth sibling root candidates are both made scope-relevant by their own `expose` + edges, the connections/parts score heuristic breaks the tie, selecting the candidate with the + better score even when its qualified name is shorter — proving the tie-break is depth-based, not + a raw qualified-name-length comparison. ##### Test Scenarios @@ -67,3 +71,4 @@ configuration are required beyond a standard .NET SDK installation. | `InterconnectionView_BuildLayout_ExposeMultipleParts_UnionsBothSubtrees` | Two exposed parts union both subtrees | | `InterconnectionView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | | `InterconnectionView_BuildLayout_ExposeInnerPartOfNestedDefinition_SelectsNestedDefinitionNotAncestor` | Nested wins | +| `InterconnectionView_BuildLayout_ExposeBothSameDepthSiblings_ScoreBreaksTieNotLength` | Score breaks the tie | diff --git a/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md index 00cee24f..2f989089 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/sequence-view-layout-strategy.md @@ -32,8 +32,10 @@ configuration are required beyond a standard .NET SDK installation. - A view whose resolved `Expose` edge names a definition unrelated to any candidate root selects no root, producing the minimal empty canvas. - A view whose resolved `Expose` edge names a single lifeline narrows the diagram to that lifeline - (plus any lifeline still reachable via a surviving message), dropping messages whose other - endpoint was excluded. + (plus any lifeline still reachable via a surviving message), dropping any message that + references the excluded lifeline (both endpoints excluded, or one excluded endpoint) while a + message between two lifelines that remain in scope — including a self-message on the retained + lifeline — is still drawn. - A view with an `expose` statement naming two separate lifelines unions both, keeping every message between them. - A view whose resolved `Expose` edge names a feature usage (not a definition) still resolves to @@ -55,7 +57,7 @@ configuration are required beyond a standard .NET SDK installation. | `SequenceView_BuildLayout_ExposeNonHeuristicRoot_SelectsExposedRoot` | Non-heuristic root is selected | | `SequenceView_BuildLayout_ExposeInnerChildOfNonHeuristicRoot_SelectsItsRoot` | Inner lifeline selects root | | `SequenceView_BuildLayout_ExposeUnrelatedDefinition_NoRootSelected_ReturnsMinimalCanvas` | Unrelated def | -| `SequenceView_BuildLayout_ExposeSingleLifeline_NarrowsLifelines` | Single exposed lifeline narrows the diagram | -| `SequenceView_BuildLayout_ExposeBothLifelines_UnionsSubtreesKeepsMessages` | Two lifelines union, keeping messages | +| `SequenceView_BuildLayout_ExposeSingleLifeline_NarrowsLifelines` | Drops `req`/`resp`; keeps `self` message | +| `SequenceView_BuildLayout_ExposeBothLifelines_UnionsSubtreesKeepsMessages` | Two lifelines union, keep messages | | `SequenceView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot` | Usage resolves via `Typing` to root | | `SequenceView_BuildLayout_ExposeInnerLifelineOfNestedDefinition_SelectsNestedDefinitionNotAncestor` | Nested wins | diff --git a/requirements.yaml b/requirements.yaml index 77ddb0a4..ea07f586 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -21,6 +21,7 @@ includes: - docs/reqstream/sysml2-tools-core/layout.yaml - docs/reqstream/sysml2-tools-core/layout/internal.yaml - docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml + - docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml - docs/reqstream/sysml2-tools-core/layout/internal/interconnection-view-layout-strategy.yaml - docs/reqstream/sysml2-tools-core/layout/internal/state-transition-view-layout-strategy.yaml - docs/reqstream/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.yaml diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs index 3a7490d7..b2d8ae6c 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs @@ -120,12 +120,11 @@ public static bool IsRootRelevantToScope(string candidateQualifiedName, IReadOnl /// Decides, for the scoped case, whether should replace /// the current best scope-relevant root candidate. Specificity (containment depth) is compared /// first: because SysML v2 qualified names are built by parent::child concatenation, any - /// genuine descendant's qualified name is strictly longer than its ancestors', so a longer - /// qualified name is a safe, cheap proxy for "more deeply nested" and always wins over a shorter - /// one regardless of score. Each strategy's own score heuristic (transition/connection+part/ - /// succession+action/message count) is used only as a fallback to break ties between candidates - /// of equal qualified-name length (e.g. unrelated siblings), via - /// . + /// genuine descendant has strictly more "::"-separated segments than its ancestors, so the + /// candidate with the greater containment depth always wins over a shallower one regardless of + /// score. Each strategy's own score heuristic (transition/connection+part/succession+action/ + /// message count) is used only as a fallback to break ties between candidates of equal + /// containment depth (e.g. unrelated siblings), via . /// /// The candidate root definition's qualified name. /// @@ -134,7 +133,7 @@ public static bool IsRootRelevantToScope(string candidateQualifiedName, IReadOnl /// /// /// Whether the candidate's own per-strategy score is better than the current best's, used only - /// when the two qualified names are equal in length. + /// when the two qualified names are equally deeply nested. /// /// /// when the candidate should become the new best root; otherwise @@ -150,8 +149,20 @@ public static bool IsMoreSpecificCandidate( return true; } - return candidateQualifiedName.Length != currentBestQualifiedName.Length - ? candidateQualifiedName.Length > currentBestQualifiedName.Length + var candidateDepth = CountSegments(candidateQualifiedName); + var currentBestDepth = CountSegments(currentBestQualifiedName); + + return candidateDepth != currentBestDepth + ? candidateDepth > currentBestDepth : currentScoreIsBetter; } + + /// + /// Counts the containment depth of a qualified name: the number of "::"-separated + /// segments. A bare simple name with no "::" separator has depth 1, not 0. + /// + /// The qualified name to measure. + /// The number of segments in . + private static int CountSegments(string qualifiedName) => + qualifiedName.Split("::", StringSplitOptions.None).Length; } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs index d92c2464..f73f6acc 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GridViewLayoutStrategy.cs @@ -86,16 +86,25 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) } /// A user-defined definition with its supertype references. - private sealed record DefRow(string Name, IReadOnlyList SupertypeNames); + private sealed record DefRow(string QualifiedName, string Name, IReadOnlyList SupertypeNames); /// /// Collects the non-stdlib definitions of the workspace in deterministic order, restricted to /// when non-null (the view's resolved expose containment /// subtrees). /// + /// + /// A definition is kept when it is directly within or it + /// participates in a specialization relationship with another definition that is in scope + /// (i.e. it is a supertype of an in-scope definition, or an in-scope definition is one of its + /// own supertypes). This "at least one dimension in scope" rule keeps both sides of a + /// specialization relationship visible in the matrix even when only one side was directly + /// exposed, so the relationship mark is never rendered against a missing row or column. + /// private static IReadOnlyList CollectDefinitions(SysmlWorkspace workspace, IReadOnlyList? scope) { - var result = new List(); + // Phase 1: collect every non-stdlib definition, unfiltered, building a full simple-name index. + var all = new List(); foreach (var qn in workspace.Declarations.Keys.OrderBy(k => k, StringComparer.Ordinal)) { if (StdlibFilter.IsStdlibElement(qn, workspace.StdlibNames)) @@ -103,14 +112,74 @@ private static IReadOnlyList CollectDefinitions(SysmlWorkspace workspace continue; } - if (scope is not null && !ExposeScopeResolver.IsInSubjectScope(qn, scope)) + if (workspace.Declarations[qn] is SysmlDefinitionNode def) + { + all.Add(new DefRow(qn, def.Name ?? qn, def.SupertypeNames)); + } + } + + // No expose scope: everything is kept (fast path, byte-identical to prior behavior). + if (scope is null) + { + return all; + } + + var fullIndexByName = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < all.Count; i++) + { + fullIndexByName.TryAdd(all[i].Name, i); + } + + // Phase 2: determine which indices are directly in the resolved scope. + var inScope = new HashSet(); + for (var i = 0; i < all.Count; i++) + { + if (ExposeScopeResolver.IsInSubjectScope(all[i].QualifiedName, scope)) + { + inScope.Add(i); + } + } + + // Phase 3: build the specialization adjacency (each definition's resolved supertype indices). + var adjacency = new List>(all.Count); + foreach (var def in all) + { + adjacency.Add(ResolveSupertypeIndices(def, fullIndexByName)); + } + + // Phase 4: keep in-scope definitions, plus any definition sharing a specialization + // relationship with an in-scope definition (as either the general or specific side). + var kept = new HashSet(inScope); + for (var j = 0; j < all.Count; j++) + { + if (!inScope.Contains(j)) { continue; } - if (workspace.Declarations[qn] is SysmlDefinitionNode def) + // j is in scope: its supertypes (general side) are kept too. + foreach (var i in adjacency[j]) + { + kept.Add(i); + } + } + + for (var i = 0; i < all.Count; i++) + { + // i's own supertypes intersect the in-scope set: i (the specific side) is kept too. + if (adjacency[i].Overlaps(inScope)) + { + kept.Add(i); + } + } + + // Phase 5: return the kept definitions in the original deterministic order. + var result = new List(kept.Count); + for (var i = 0; i < all.Count; i++) + { + if (kept.Contains(i)) { - result.Add(new DefRow(def.Name ?? qn, def.SupertypeNames)); + result.Add(all[i]); } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs index 1255c802..34c6548c 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs @@ -242,6 +242,50 @@ public void GridView_BuildLayout_ExposeMultipleTargets_UnionsBothSubtrees() Assert.Contains("B", labels); } + /// + /// A Grid View expose statement naming only the specific side of a specialization + /// relationship (Root::A::Sub, which specializes Root::A) still keeps both + /// sides visible in the matrix: the general side (A) is not in Sub's + /// containment subtree, but the two participate in the same specialization relationship, so + /// both remain as header rows/columns and the Sub->A mark is present, while + /// the unrelated Root::B is excluded. + /// + [Fact] + public void GridView_BuildLayout_ExposeOneSideOfSpecialization_KeepsBothRowAndColumn() + { + var strategy = new GridViewLayoutStrategy(); + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Root::A", DefinitionKeyword = "part def" }, + ["Root::A::Sub"] = new SysmlDefinitionNode { Name = "Sub", QualifiedName = "Root::A::Sub", DefinitionKeyword = "part def", SupertypeNames = ["A"] }, + ["Root::B"] = new SysmlDefinitionNode { Name = "B", QualifiedName = "Root::B", DefinitionKeyword = "part def" } + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ExposedNames = ["Sub"], + ResolvedEdges = [new SysmlEdge("Root::V", "Root::A::Sub", SysmlEdgeKind.Expose)] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var grid = Assert.Single(layout.Nodes.OfType()); + var labels = grid.Rows[0].Cells.Select(c => c.Text).ToList(); + Assert.Contains("A", labels); + Assert.Contains("Sub", labels); + Assert.DoesNotContain("B", labels); + + var subRow = grid.Rows.Single(r => !r.IsHeader && r.Cells[0].Text == "Sub"); + var aColumnIndex = grid.Rows[0].Cells.Select((c, i) => (c, i)).Single(x => x.c.Text == "A").i; + Assert.Equal("X", subRow.Cells[aColumnIndex].Text); + } + /// /// A Browser View with a resolved Expose edge to Root::A scopes the tree to /// Root::A's containment subtree, excluding the unrelated sibling Root::B — diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs index 0affbc0e..72f86de9 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs @@ -224,4 +224,69 @@ public void IsMoreSpecificCandidate_EqualLength_FallsBackToScore_False() { Assert.False(ExposeScopeResolver.IsMoreSpecificCandidate("Root::SysB", "Root::SysA", currentScoreIsBetter: false)); } + + /// + /// Same-depth sibling candidates with different qualified-name *lengths* fall back to the + /// caller-supplied score comparison — depth, not raw string length, drives the decision — true + /// case (the shorter, better-scoring candidate wins). + /// + [Fact] + public void IsMoreSpecificCandidate_SameDepthSiblingsDifferentLength_ShorterWithBetterScoreWins() + { + Assert.True(ExposeScopeResolver.IsMoreSpecificCandidate("Pkg::AB", "Pkg::MuchLongerSiblingName", currentScoreIsBetter: true)); + } + + /// + /// Same-depth sibling candidates with different qualified-name lengths fall back to the + /// caller-supplied score comparison — false case (the shorter candidate loses when its score + /// is not better). + /// + [Fact] + public void IsMoreSpecificCandidate_SameDepthSiblingsDifferentLength_ShorterWithWorseScoreLoses() + { + Assert.False(ExposeScopeResolver.IsMoreSpecificCandidate("Pkg::AB", "Pkg::MuchLongerSiblingName", currentScoreIsBetter: false)); + } + + /// + /// Two resolved Expose edges on the same view — one targeting a plain definition, one + /// targeting a feature usage — union both targets plus the usage's resolved type, proving the + /// usage-to-type fallback fires per-target within a multi-expose view rather than only when a + /// single usage is exposed alone. + /// + [Fact] + public void ResolveExposedScope_TwoExposeEdges_DefinitionAndUsageTarget_UnionsBothPlusResolvedType() + { + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary + { + ["Root::A"] = new SysmlDefinitionNode { Name = "A", QualifiedName = "Root::A", DefinitionKeyword = "part def" }, + ["Root::Vehicle"] = new SysmlDefinitionNode { Name = "Vehicle", QualifiedName = "Root::Vehicle", DefinitionKeyword = "part def" }, + ["Root::myVehicle"] = new SysmlFeatureNode + { + Name = "myVehicle", + QualifiedName = "Root::myVehicle", + FeatureTyping = "Vehicle", + ResolvedEdges = [new SysmlEdge("Root::myVehicle", "Root::Vehicle", SysmlEdgeKind.Typing)] + } + } + }; + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "Root::V", + ResolvedEdges = + [ + new SysmlEdge("Root::V", "Root::A", SysmlEdgeKind.Expose), + new SysmlEdge("Root::V", "Root::myVehicle", SysmlEdgeKind.Expose) + ] + }; + + var scope = ExposeScopeResolver.ResolveExposedScope(workspace, viewNode); + + Assert.NotNull(scope); + Assert.Contains("Root::A", scope); + Assert.Contains("Root::myVehicle", scope); + Assert.Contains("Root::Vehicle", scope); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs index da43b8f9..cad4e28b 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/InterconnectionViewLayoutStrategyTests.cs @@ -560,6 +560,82 @@ public void InterconnectionView_BuildLayout_ExposeNonHeuristicRoot_SelectsExpose Assert.Equal("SysB", container.Label); } + /// + /// Builds a workspace with two same-depth sibling candidate roots whose qualified names + /// have very different lengths: M::AB (short name, two connections/three parts — the + /// better score) and M::MuchLongerSiblingName (long name, one connection/two parts — + /// the worse score). + /// + private static SysmlWorkspace BuildSameDepthDifferentLengthWorkspace() + { + var shortName = new SysmlDefinitionNode + { + Name = "AB", + QualifiedName = "M::AB", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "a1", QualifiedName = "M::AB::a1", FeatureKeyword = "part", FeatureTyping = "A" }, + new SysmlFeatureNode { Name = "a2", QualifiedName = "M::AB::a2", FeatureKeyword = "part", FeatureTyping = "A" }, + new SysmlFeatureNode { Name = "a3", QualifiedName = "M::AB::a3", FeatureKeyword = "part", FeatureTyping = "A" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "a1", EndpointB = "a2" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "a2", EndpointB = "a3" } + ] + }; + var longName = new SysmlDefinitionNode + { + Name = "MuchLongerSiblingName", + QualifiedName = "M::MuchLongerSiblingName", + DefinitionKeyword = "part def", + Children = + [ + new SysmlFeatureNode { Name = "b1", QualifiedName = "M::MuchLongerSiblingName::b1", FeatureKeyword = "part", FeatureTyping = "B" }, + new SysmlFeatureNode { Name = "b2", QualifiedName = "M::MuchLongerSiblingName::b2", FeatureKeyword = "part", FeatureTyping = "B" }, + new SysmlConnectionNode { ConnectionKeyword = "connection", EndpointA = "b1", EndpointB = "b2" } + ] + }; + return new SysmlWorkspace + { + Declarations = new Dictionary + { + ["M::AB"] = shortName, + ["M::MuchLongerSiblingName"] = longName + } + }; + } + + /// + /// When both same-depth sibling roots are made scope-relevant by their own expose + /// edges, FindRoot falls back to the connections/parts score heuristic — proving the + /// tie-break is depth-based, not a raw qualified-name-length comparison, since the shorter + /// name (M::AB) wins purely because it has the better score, not because it happens + /// to be shorter. + /// + [Fact] + public void InterconnectionView_BuildLayout_ExposeBothSameDepthSiblings_ScoreBreaksTieNotLength() + { + var strategy = new InterconnectionViewLayoutStrategy(); + var workspace = BuildSameDepthDifferentLengthWorkspace(); + var viewNode = new SysmlViewNode + { + Name = "V", + QualifiedName = "M::V", + ExposedNames = ["AB", "MuchLongerSiblingName"], + ResolvedEdges = + [ + new SysmlEdge("M::V", "M::AB", SysmlEdgeKind.Expose), + new SysmlEdge("M::V", "M::MuchLongerSiblingName", SysmlEdgeKind.Expose) + ] + }; + var context = new ViewContext("v", workspace, viewNode); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var container = layout.Nodes.OfType().First(b => b.Keyword == "part def"); + Assert.Equal("AB", container.Label); + } + /// /// An expose edge pointing at an inner child of a non-heuristic root /// (M::SysB::b1) still selects SysB as the root, since the exposed subject diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs index 8b5f29a2..e16bb05c 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/SequenceViewLayoutStrategyTests.cs @@ -444,6 +444,13 @@ public void SequenceView_BuildLayout_ExposeSingleLifeline_NarrowsLifelines() Assert.DoesNotContain("client", scopedLifelines); Assert.True(scopedLifelines.Count < fullLifelines.Count, $"expected scoped ({scopedLifelines.Count}) < full ({fullLifelines.Count})"); + + // Messages referencing the excluded client lifeline are dropped; the server-only self + // message survives. + var scopedLines = scoped.Nodes.OfType().Select(l => l.MidpointLabel).ToList(); + Assert.DoesNotContain("req", scopedLines); + Assert.DoesNotContain("resp", scopedLines); + Assert.Contains("self", scopedLines); } /// From cff58fa1a1e110e628a93a036be83b9883a8c476 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 6 Jul 2026 23:05:50 -0400 Subject: [PATCH 4/4] Add expose-scoping gallery examples for Grid, Interconnection, and Sequence views Adds CarLineageGridView, CoreLinkInterconnectionView, and TokenExchangeSequenceView alongside the existing gallery views, demonstrating the shared ExposeScopeResolver scoping behavior now implemented across all seven layout strategies, including Grid View's relation-preserving 'at least one dimension in scope' rule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/gallery/README.md | 42 +++++++++ .../models/02-computer-interconnection.sysml | 8 ++ docs/gallery/models/05-oauth-sequence.sysml | 9 ++ docs/gallery/models/06-vehicle-grid.sysml | 8 ++ docs/gallery/png/CarLineageGridView.png | Bin 0 -> 5984 bytes .../png/CoreLinkInterconnectionView.png | Bin 0 -> 6506 bytes .../gallery/png/TokenExchangeSequenceView.png | Bin 0 -> 6813 bytes docs/gallery/svg/CarLineageGridView.svg | 83 ++++++++++++++++++ .../svg/CoreLinkInterconnectionView.svg | 45 ++++++++++ .../gallery/svg/TokenExchangeSequenceView.svg | 47 ++++++++++ 10 files changed, 242 insertions(+) create mode 100644 docs/gallery/png/CarLineageGridView.png create mode 100644 docs/gallery/png/CoreLinkInterconnectionView.png create mode 100644 docs/gallery/png/TokenExchangeSequenceView.png create mode 100644 docs/gallery/svg/CarLineageGridView.svg create mode 100644 docs/gallery/svg/CoreLinkInterconnectionView.svg create mode 100644 docs/gallery/svg/TokenExchangeSequenceView.svg diff --git a/docs/gallery/README.md b/docs/gallery/README.md index e0c89e6c..c721c73d 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -58,6 +58,20 @@ SVG: [`svg/WorkstationInterconnectionView.svg`](svg/WorkstationInterconnectionVi ![Workstation Interconnection View](png/WorkstationInterconnectionView.png) +### 2b. View-scoped rendering — `expose` narrows the diagram to two parts + +The same model also declares `CoreLinkInterconnectionView`, which exposes only +`Workstation::cpu` and `Workstation::memory`. The root part (`Workstation`) is kept, +but every other part usage — and every connector with an endpoint outside the exposed +scope (to `board`, `graphics`, `storage`, `psu`, `network`) — is dropped, leaving just +the two exposed parts and the connection directly between them (`c8`). + +Model: [`models/02-computer-interconnection.sysml`](models/02-computer-interconnection.sysml) +(`CoreLinkInterconnectionView`) · +SVG: [`svg/CoreLinkInterconnectionView.svg`](svg/CoreLinkInterconnectionView.svg) + +![Core Link Interconnection View](png/CoreLinkInterconnectionView.png) + --- ## 3. State Transition View — Elevator Controller @@ -94,6 +108,20 @@ SVG: [`svg/OAuthSequenceView.svg`](svg/OAuthSequenceView.svg) ![OAuth Sequence View](png/OAuthSequenceView.png) +### 5b. View-scoped rendering — `expose` narrows the diagram to two lifelines + +The same model also declares `TokenExchangeSequenceView`, which exposes +`AuthorizationFlow::browser` and `AuthorizationFlow::authServer`. Only those two +lifelines remain, and every message with an endpoint on `user` or `resourceServer` +(`openApp`, `promptCredentials`, `submitCredentials`, `fetchResource`, `resourceData`) +is dropped as dangling, leaving just the token-exchange leg of the flow (`redirect`, +`authCode`, `exchangeCode`, `accessToken`). + +Model: [`models/05-oauth-sequence.sysml`](models/05-oauth-sequence.sysml) (`TokenExchangeSequenceView`) · +SVG: [`svg/TokenExchangeSequenceView.svg`](svg/TokenExchangeSequenceView.svg) + +![Token Exchange Sequence View](png/TokenExchangeSequenceView.png) + --- ## 6. Grid View — Vehicle Taxonomy @@ -106,6 +134,20 @@ SVG: [`svg/TaxonomyMatrixView.svg`](svg/TaxonomyMatrixView.svg) ![Vehicle Taxonomy Matrix View](png/TaxonomyMatrixView.png) +### 6b. View-scoped rendering — `expose` narrows the matrix along specialization + +The same model also declares `CarLineageGridView`, which exposes only `Car`. Because +a matrix cell inherently relates two definitions, Grid View keeps a row/column when +either dimension is in scope: `Car` itself, its supertype `LandVehicle`, and its +subtypes `Sedan` and `SportsCar` all remain, while every unrelated definition +(`Vehicle`, `WaterVehicle`, `AirVehicle`, `Truck`, `PickupTruck`, `Motorcycle`, +`Boat`, `Submarine`, `Airplane`, `Helicopter`) is dropped. + +Model: [`models/06-vehicle-grid.sysml`](models/06-vehicle-grid.sysml) (`CarLineageGridView`) · +SVG: [`svg/CarLineageGridView.svg`](svg/CarLineageGridView.svg) + +![Car Lineage Grid View](png/CarLineageGridView.png) + --- ## 7. Browser View — Avionics System diff --git a/docs/gallery/models/02-computer-interconnection.sysml b/docs/gallery/models/02-computer-interconnection.sysml index a48276d2..9bf0af39 100644 --- a/docs/gallery/models/02-computer-interconnection.sysml +++ b/docs/gallery/models/02-computer-interconnection.sysml @@ -29,4 +29,12 @@ package DesktopComputer { } view def WorkstationInterconnectionView {} + + // View-scoped rendering: exposing just cpu and memory narrows the diagram to those + // two parts and the connection between them (c8), dropping every connection with an + // endpoint outside the exposed scope (board, graphics, storage, psu, network). + view CoreLinkInterconnectionView { + expose Workstation::cpu; + expose Workstation::memory; + } } diff --git a/docs/gallery/models/05-oauth-sequence.sysml b/docs/gallery/models/05-oauth-sequence.sysml index d5abf9bf..7bdc465a 100644 --- a/docs/gallery/models/05-oauth-sequence.sysml +++ b/docs/gallery/models/05-oauth-sequence.sysml @@ -35,4 +35,13 @@ package OAuthLogin { } view def OAuthSequenceView {} + + // View-scoped rendering: exposing browser and authServer narrows the sequence to the + // token-exchange leg of the flow, dropping every message with an endpoint on user or + // resourceServer (openApp, promptCredentials, submitCredentials, fetchResource, + // resourceData) while keeping the four messages exchanged between the two lifelines. + view TokenExchangeSequenceView { + expose AuthorizationFlow::browser; + expose AuthorizationFlow::authServer; + } } diff --git a/docs/gallery/models/06-vehicle-grid.sysml b/docs/gallery/models/06-vehicle-grid.sysml index 993f0d7a..e6f7a154 100644 --- a/docs/gallery/models/06-vehicle-grid.sysml +++ b/docs/gallery/models/06-vehicle-grid.sysml @@ -22,4 +22,12 @@ package VehicleTaxonomy { part def PickupTruck :> Truck; view def TaxonomyMatrixView {} + + // View-scoped rendering: exposing Car narrows the matrix to Car itself plus every + // definition directly related to it by specialization in either direction (its + // supertype LandVehicle, and its subtypes SportsCar and Sedan) — demonstrating Grid + // View's "at least one dimension in scope" relation-preserving expose rule. + view CarLineageGridView { + expose Car; + } } diff --git a/docs/gallery/png/CarLineageGridView.png b/docs/gallery/png/CarLineageGridView.png new file mode 100644 index 0000000000000000000000000000000000000000..e7a158ed225473e496a13897a2276ce82952d4d3 GIT binary patch literal 5984 zcmb7IbyQT_{y%_pj-Z4RBB?l}N|&V4QX-9%bV~>*HH3gDNJ}XqF@$utbcrYff^^7? z3^7Q^Z=-kJd*8e7x7K^-4-R{+Jv%@3J&{@(%9LbGWDo>Vs;VgHKoA}p{2xz31pW@H zAZ~-#DGzy7T@vsFklaH+5Iw!Bf}HMy^o?m3Ejrsw;_ogcXv%rtbYUZ}dP?VV_E?e_ zMG6J^VT9^EWfLl^@|v?cHKkhV(W(W4@&l$ZrCHA{C40rDv@8)!1Rh19qpk^A5``Am zZ$;)v$TjFiz+H0Vy+$%$OpyyU3JXyPh>VE@%IN8<9Q$VG2T6@J`(_6EEfuu3w#w1K zU{ARYLnbNN*x7T+tme4b*+*%(oY~pgDJ}k&Mtsoup{~c~sYTvkM5(6=Wgf<;if(lU zg}e*%Yi{*kUU8T)JaskCg<%!qc^2c5r(9jegfaEv47gbjGgNo9)PFtYlgL<+uLl0T z+{S}X(3!BlH4LWv+$!VVP>3Ghi_JVsNxO1Ex3N%@)rKe&i6|JeeFDGH(b3WU>z7wP z)R@yRI5Y0@z4>&RU3@e&m-PdYafvBX*|c?N^Zr^Pty7LofNz;e>@paTHfrV>Jh|)} z7(PEgt-ImKv&Qsf;DC|IaLV=TbFF5qSw%T?NzV@^8jGp|&xB6aFjQ_p-e%yh8t9CWJh zdbivMp15T=tz+vpuHf0r_Xl+mhRt?P z?a1Bd7sW?Ul^N)+YjI7hlzl%F@p$TrY1tmuQsRrskgR8(z2VSYSe^nDJg-UR`}sJ) zn<_ddm&PIL(WlG3jnUUfg}*O$dcWM*(!;^#~5Us5N{?X+~7WI6!?6ukA)tOe}qIeI$8xqG-2S2r zpV;WASRU}m?aL}5#OLcw>gT*YYc%2I=znm9PXONX?Q;d5?z}_ygz1ghi{l$TCP~&l zWerz?El7Qfyfm)*HL^5I8$BU7llZCDiT%n8&dZ#1>#UM_GtN%=aC(4)49($R_l+cSiL=|Uj+FUFu#z1UqrIei<=-C zFjdtvZRGq-Y%Um<-t%$YD8WhZn;#g}5*8k7(Bn6n#uW{ux#G~rU%|;-P=z;lFR;KE zR}Up4F$6(np6B*Z!H2lT*giu#=CE3s7y2f-_zXX~+@FTQaH$GvXXB~~9H@y{r@9v>8>FH-l_)v-IYTMQ=+J%iK z?|>&V?}A?G3x~YLX&@)0#C+7Iy3R$b%m+ZS?Y!qa#WRNd-?R+7`hrG@mq~XTstTWB z&5J)Owk>6;B3Wt~6Nv+gW1jB&!9*YQ3C=bhIr7Ifo=zF{A{tv>mhJ{H`4M1(u8Fbk zdzL*GwkP$vER%J%$Cq_4`V|U5n=e-QYg%)_#hGwKGV6urE=j8&d;TkKXqJN$?c0Jo z&(uBn>|rwx`Vc_Lf>z-(>*ErgF8+yP<3ne-^G8B|DP|#0XGpVj3((euZlTjjT(kKp z=gf=WcBN|bG;1d8cF(+xT#TtHN!kO zaez2{hJEwO<0CS@*t>ky%kIkD@=D3c^KtcjxX=vhxQ{)m{S&|L5d9^yet;i-s}0_# z0sV}#f48;8(L{YB6+n=tzWp%sdRbZ9dGAPfHAfs|Lq!hk(aw+K?u^Y2}ej6^k*N|C9Mt$Yt@-j^x5% zHv?sXxxn0d;^QhmW`JJ{Lw=mfMLI)QJ>ntEa!j=H%!s zEZfS_NnD7^`1Ed-a>et)P5m1R(s}u5o3pKuxOfRZbUv14d-sc?U)`O>B-qmgI`t_| z&O}kySt*7mFl>2E4Z{yiQqr}1HqMXOlH*fq2{rOb%vVrW-*&z8UfWQt3(|~yT3oix zE-z-wNOmnd7BwNa?uX;w+xG&Zjj0R4%g>6|>qQd|^(~23nHLqD7?BYYR z1}_LGDX)IZpj8hZVR+Qh!TG)qb@B?FiBrf^kJ_nWm0~)Q_I^;q#=Z3B4Iu-=10JMs znt_l#Eiaq2G*xjiJq;^szV~f}-RVn(CEv-1%FMxIV$WO6b~cU)mR1MD|6>>>5) zs!c}+gII359h1(x?u=&@un20_MD7f{_~RrFdYb{Vu-2>yj@sc5+bIcpv{IWKJz;fq zME7iMA*j4U*mXgLd4GRj&zTX%t2)w=k?9=%x#6^;qBYiI>dcu4JV;fQl!P*aR8HUm zzYDRBPC{Z)>Pn=6o?ghicX|R0+Yl5Y7nh#?Kh`y7E#xshBGi9m>iMyCDM;K=H+R{&v>LgNIWsa^Q{ zPhrwRG{ynv)tCb$m6(4g$^QYA>YUEeug+5`4~6z^UMG|h_UL=lY}U`8bAg?`k|(t$ z@n(^gwxMB6Y;2)vA2@{zBwQ*9muKDMm?b!lGX()TbaM+>s<|tCIRlRmsF2n{hx2C~ zr&Gx@;0mwwVF1A;Ssdtp!yONj_7v@J+{x0Yl^QuLdTHeIU~J2t&$ou)`A%01bq3tba>vAkDqYM2|GINPWHf!VymGgmN&3PR1S9gCXEbhg?x8y#awGz z11F@TqjlN`V4B)YS@B9sOUn)94(JR8YtTP_VW!bleAmZyITj)2VRI1dRC9A;O69nAX8s!v97P&UW?xi6IC05;y|W^bLv zx|Dypu+7Rs^UFZ-ewW(J_QipL<@kgIKBR+FBP1u#a}3Q6W;9b<#p&kf4-cmVuCv~f z0%*BOwldM$)$Z(~Y&C!9yy$m01(6hB&3TzG0a{*p5VEm~ zh?p9kqJ>f}G%N5!AP`;mNByTjMk%_J4FDa_L7$o`eQqq!W0`CP-NZZN?=7wv@V#}@ z0+ljwS=(SDnDzKUVne@OOiT5Qbnx^SIR80Jo?xyZ{`B?tPPGTFtFM29MorW`kILG*v%SiH_p{- zq`it?9pmzl*$KKkIy+mwuE@DyOdMxSv%JTEdT5rh;2Qmp5Hab*A#&6Zs4!?);P2Nn zG+<$2oiwLt$;hL>dPZU0efXy>dAmc12}~EO-o5Kt7Z$F5e?a<})Z1S|=e95#3727U z@2pSA(n(lc zLn{p!5XktEW6H6YE9)p83(G1gAtNB5hMhY&UG&iiOav|T*Z@t2f(=aX#P08N zfmL;+i&enr=?NebYWh;&Eh40QdpjX8Q7HItX4LD|eCRIZJYARj;cGnp-2D9Tq#xeq zrrU|%*iCAAx~H~gb?n7!_AkiBYa(js zAD9%|C93-Tjh?@#3iPMAnDW_(aq6inSDZ-54_HRW1`v^g?n+{?r}A{SrZnCwEvh!K7v|W6z+0S72cnE=F*Uk%l&<|f~8T81P=PO zHKzEC3_R%l;9$$si(Ub}!3Y&RaFeKN|IMYe=I0S@5)#PlynFc#O?mgYjf^H2c7Wl_ z{|9DbrT=b%5r=ZYudCj^Wy*V1obXZrNXGk~&xq7i+Dp|j2$h3Ort{!g?*gkkEZdp2 zoEitT|MM#d`SzsrV$+EMB9yv1etkGmn*t)n)b_jak7zdJhkg2Jc5dE$ZFXJ|@NvAg zhS)4C_tUrE@s9TIS?~XCAaWv4&d_G*EXiZif&xa6zY_UOmt42AkjNl4l=rX1R(2>c z3s@7+F_HH!IvjAd)UdZPX#-_&)gDRAp2Hn!8Cj1+vi^=;Ccuc8nC^d{@`z5n^ri1> zyRsYR%gV1=w6%G$TNP|k88n?5j+5I&o^|o6_Vkphn%7VK&(qVGP+(iGuo?#kSN;3Q zz&+#8I~RrAS%7pyvfu`Q>mcd>=AwJw+cg(XY!nv-sN3S~f~$SmsY6v*d3I+~E{LzTp&8@7I(|8XE{ykr2I=;DN=~TiYT!Ox{+eB-ryBEvn8vbV3ltecNd(AiOCrvA{O>* zyPQC*+R>cHE6TjsDtr4v*VR!2S5ZF!2YsG|gwqfNcz5nv-u&T@Ti8Hg*v&m##4;qm z>@VM0L}_*;cLZY<5kc-=tF28gAn<+I;mB?$AqYr;ol|4*-k#`h2J?uE4VVvGf&6P+ zizNgz&t>@B3@^qlXgmj2)|fPuoV~RlG<3notgW38*lwoe_C`~+I8$2j9hOU)s&|$v zjLD4uHP+fqCO_|P1$RtFRt}=cLgmyJix3oWd~i$#)p!+7IQ+;}9<5myVY*oAk3;eB zevSl{L4QiF@*g=Eh1%G#bCkTS`P#wBiNPmBr2O9_Fhb=*6sL3k?QK7WzF(qIK5-4C z2#7+R%fV4Hdc`g`05c}lzu_4r8aNeh-Io+=IN^XS-ed~L6`{ax2h%@8!640w2JF3= zASS}i{97y_-}}^)#!;&UQoy%w6p*a9H&N{Qf^%CJ0~8cQggQ^-Hc*WZwg$|4h3o$i zmx*g5BTAqIG5>kEyov4W!9Lvh?d&0J$y&fia&n5LxvOi7&6QFH#kz^& zJ|&~cOAHi#pawcI8Ex&9sgkIub|^S6?^I~`U?`|^UQ}Csil87f*3)|uKoy)#efuQw z-S&_N1rp4D=8ppMQw9&gk20^HD*m%vr+vuIn^pWNA`{+$i@bl8iYmjWuSb{zVd9GY zUGkCIoz#ag-jHX&Y7wu?+;&c4fQrfgBt0bmL6i<457}4o!6$P_RZ&BsMBe=I F{{fTMNQeLc literal 0 HcmV?d00001 diff --git a/docs/gallery/png/CoreLinkInterconnectionView.png b/docs/gallery/png/CoreLinkInterconnectionView.png new file mode 100644 index 0000000000000000000000000000000000000000..84e3c66bf6eeca6c5c0d25ccd094eba779f3186e GIT binary patch literal 6506 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Car + + LandVehicle + + Sedan + + SportsCar + + Car + + + + X + + + + + + LandVehicle + + + + + + + + + + Sedan + + X + + + + + + + + SportsCar + + X + + + + + + + diff --git a/docs/gallery/svg/CoreLinkInterconnectionView.svg b/docs/gallery/svg/CoreLinkInterconnectionView.svg new file mode 100644 index 00000000..f2d49133 --- /dev/null +++ b/docs/gallery/svg/CoreLinkInterconnectionView.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «part def» + Workstation + + «part» + cpu : Cpu + + «part» + memory : Ram + + + + diff --git a/docs/gallery/svg/TokenExchangeSequenceView.svg b/docs/gallery/svg/TokenExchangeSequenceView.svg new file mode 100644 index 00000000..1cc2ea29 --- /dev/null +++ b/docs/gallery/svg/TokenExchangeSequenceView.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + browser + + + authServer + + + + + + redirect + authCode + exchangeCode + accessToken +