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.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.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..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 @@ -18,16 +18,28 @@ 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`. 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 +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 +70,30 @@ 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. 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 Null `context` or `options` arguments throw `ArgumentNullException`. The absence of an eligible @@ -73,6 +109,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..e0763373 --- /dev/null +++ b/docs/design/sysml2-tools-core/layout/internal/expose-scope-resolver.md @@ -0,0 +1,103 @@ +#### 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). 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, 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 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. + +##### Error Handling + +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. + +##### 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 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). +- `InterconnectionViewLayoutStrategy`, `StateTransitionViewLayoutStrategy`, + `ActionFlowViewLayoutStrategy`, and `SequenceViewLayoutStrategy` call `ResolveExposedScope` to + 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/general-view-layout-strategy.md b/docs/design/sysml2-tools-core/layout/internal/general-view-layout-strategy.md index d965467a..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 @@ -34,13 +34,14 @@ 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 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 is never misled into skipping the hierarchical engine. When any package folder was depth-truncated, @@ -50,37 +51,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 +135,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..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 @@ -20,20 +20,55 @@ 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, 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. -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. 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 @@ -48,6 +83,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..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 @@ -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,47 @@ 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`. 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)` `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. 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 +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 +142,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..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 @@ -20,25 +20,65 @@ 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`. 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`). 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. 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" +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 +93,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..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 @@ -19,17 +19,27 @@ 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`. 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. ###### `ResolveTransitions(root, index)` @@ -65,6 +75,29 @@ 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. 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 Null `context` or `options` arguments throw `ArgumentNullException`. The absence of an eligible @@ -80,6 +113,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/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/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 00000000..e7a158ed Binary files /dev/null and b/docs/gallery/png/CarLineageGridView.png differ diff --git a/docs/gallery/png/CoreLinkInterconnectionView.png b/docs/gallery/png/CoreLinkInterconnectionView.png new file mode 100644 index 00000000..84e3c66b Binary files /dev/null and b/docs/gallery/png/CoreLinkInterconnectionView.png differ diff --git a/docs/gallery/png/TokenExchangeSequenceView.png b/docs/gallery/png/TokenExchangeSequenceView.png new file mode 100644 index 00000000..b576a489 Binary files /dev/null and b/docs/gallery/png/TokenExchangeSequenceView.png differ diff --git a/docs/gallery/svg/CarLineageGridView.svg b/docs/gallery/svg/CarLineageGridView.svg new file mode 100644 index 00000000..cb51af35 --- /dev/null +++ b/docs/gallery/svg/CarLineageGridView.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + 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 81f29a2b..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 @@ -77,3 +77,74 @@ sections: tests: - ActionFlowView_BuildLayout_BranchAndJoin - ActionFlowView_BuildLayout_Cycle_IsBroken + + - 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. + 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, 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_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 + — 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 + + - 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/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..5c857722 --- /dev/null +++ b/docs/reqstream/sysml2-tools-core/layout/internal/expose-scope-resolver.yaml @@ -0,0 +1,105 @@ +--- +# 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. 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: >- + 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 + + - id: SysML2Tools-Core-Layout-Internal-ExposeScopeResolver-SpecificityTieBreak + title: >- + 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. 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 b445c0a4..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 @@ -30,3 +30,39 @@ 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), 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. 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: >- + 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..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 @@ -118,3 +118,74 @@ sections: still drawing the part. tests: - InterconnectionView_BuildLayout_SelfReferentialType_TreatedAsLeaf + + - 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. + 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, 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 + + - 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 + + - 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 + - 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 4390b115..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 @@ -50,3 +50,87 @@ 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 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; 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-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. + 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, 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 + + - 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 + + - 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 b47a7562..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 @@ -72,3 +72,74 @@ sections: clear and matches the block-diagram visual style used across the tool. tests: - StateTransitionView_BuildLayout_ForwardChain_FlowsTopToBottomOrthogonally + + - 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. + 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, 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_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 + — 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 + + - 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/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.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/action-flow-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/action-flow-view-layout-strategy.md index 3cb7a41c..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 @@ -28,6 +28,23 @@ 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. +- 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 @@ -41,3 +58,10 @@ 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 | +| `ActionFlowView_BuildLayout_ExposeInnerActionOfNestedDefinition_SelectsNestedDefinitionNotAncestor` | Nested wins | 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..9e66ce37 --- /dev/null +++ b/docs/verification/sysml2-tools-core/layout/internal/expose-scope-resolver.md @@ -0,0 +1,89 @@ +#### ExposeScopeResolver Verification + +##### Verification Approach + +`ExposeScopeResolver` is verified through direct unit tests in `ExposeScopeResolverTests` that +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 + +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. +- 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). +- `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. +- 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 are equally deeply nested (whether their qualified names are equal in length or + merely equal in containment depth). + +##### 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 +- `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`: + 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 +- `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 +- `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 4bafbfb6..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 @@ -19,6 +19,19 @@ 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. +- 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 @@ -26,3 +39,8 @@ 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 | +| `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 8ff67b2b..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 @@ -29,6 +29,27 @@ 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. +- 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 @@ -42,3 +63,12 @@ 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 | +| `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 01699742..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 @@ -19,6 +19,30 @@ 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 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 + 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 @@ -28,3 +52,12 @@ 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` | 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/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..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 @@ -26,6 +26,22 @@ 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. +- 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 @@ -37,3 +53,10 @@ 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 | +| `StateTransitionView_BuildLayout_ExposeInnerStateOfNestedDefinition_SelectsNestedDefinitionNotAncestor` | Nested | 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/ActionFlowViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs index 78fc7ba3..c891aa5a 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,10 +115,22 @@ 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 + /// ; 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) @@ -131,12 +145,23 @@ 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; - 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; } } @@ -144,10 +169,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 +199,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) + { + continue; + } + + if (scope is not null && feature.QualifiedName is { Length: > 0 } fqn && + !ExposeScopeResolver.IsInSubjectScope(fqn, scope)) { - Add(feature.Name); + 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..b2d8ae6c --- /dev/null +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ExposeScopeResolver.cs @@ -0,0 +1,168 @@ +// +// 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). + /// + /// + /// 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. + /// + /// 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)); + + /// + /// 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 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. + /// + /// 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 equally deeply nested. + /// + /// + /// 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; + } + + 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/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..f73f6acc 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, []); @@ -86,12 +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); - - /// Collects the non-stdlib definitions of the workspace in deterministic order. - private static IReadOnlyList CollectDefinitions(SysmlWorkspace workspace) + 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)) @@ -101,7 +114,72 @@ private static IReadOnlyList CollectDefinitions(SysmlWorkspace workspace if (workspace.Declarations[qn] is SysmlDefinitionNode def) { - result.Add(new DefRow(def.Name ?? qn, def.SupertypeNames)); + 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; + } + + // 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(all[i]); } } diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/InterconnectionViewLayoutStrategy.cs index cd2caebf..033e9120 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,11 +249,23 @@ 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; 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) + private static SysmlDefinitionNode? FindRoot(SysmlWorkspace workspace, IReadOnlyList? scope) { SysmlDefinitionNode? best = null; + string? bestQualifiedName = null; var bestConnections = -1; var bestParts = -1; @@ -263,12 +281,22 @@ 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"); + 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; } @@ -281,14 +309,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 +331,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..d8a40abe 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,10 +108,22 @@ 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 + /// ; 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) @@ -124,10 +138,21 @@ 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) + var scoreBetter = messages > bestMessages; + var isBetter = scope is not null + ? ExposeScopeResolver.IsMoreSpecificCandidate(qualifiedName, bestQualifiedName, scoreBetter) + : scoreBetter; + + if (isBetter) { best = def; + bestQualifiedName = qualifiedName; bestMessages = messages; } } @@ -137,9 +162,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 +187,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..d794f15e 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,9 +204,23 @@ 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 + /// ; 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) @@ -219,10 +235,21 @@ 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) + var scoreBetter = transitions > bestTransitions; + var isBetter = scope is not null + ? ExposeScopeResolver.IsMoreSpecificCandidate(qualifiedName, bestQualifiedName, scoreBetter) + : scoreBetter; + + if (isBetter) { best = def; + bestQualifiedName = qualifiedName; bestTransitions = transitions; } } @@ -232,11 +259,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 +289,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..b36b6180 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs @@ -375,4 +375,295 @@ 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"); + } + + /// + /// 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. + /// + [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..34c6548c 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/BrowserAndGridViewLayoutStrategyTests.cs @@ -87,4 +87,314 @@ 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 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 — + /// 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..72f86de9 --- /dev/null +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ExposeScopeResolverTests.cs @@ -0,0 +1,292 @@ +// +// 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"])); + } + + /// 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)); + } + + /// + /// 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 5d06a2e7..cad4e28b 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) { @@ -386,4 +468,321 @@ 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); + } + + /// + /// 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 + /// 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..e16bb05c 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,379 @@ 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); + } + + /// + /// 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. + /// + [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})"); + + // 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); + } + + /// + /// 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..ccfeeb76 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/StateTransitionViewLayoutStrategyTests.cs @@ -232,4 +232,292 @@ 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"); + } + + /// + /// 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. + /// + [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"); + } }