diff --git a/ROADMAP.md b/ROADMAP.md index e26d0052..dd39f2b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,12 +80,38 @@ implemented first). and renderers; possibly `LayoutLabel`/compartment tweaks. **Visual gate:** a documented requirement/part renders its note and full compartments. -### View dynamics refinements - -- **Sequence View:** populate `LayoutActivation` execution bars; combined-fragment boxes - (alt/opt/loop); async/reply message styling. -- **Action Flow View:** **fork/join** thick bars, **decision/merge** diamonds, accept/send - action shapes; optional **swim-lanes** via `LayoutBand`; item-flow edge annotations. +### Action Flow View: control-node/successor AST correctness + fork/join/decision/merge shapes — delivered + +**Delivered.** `AstBuilder.VisitActionBodyItem` now synthesizes a `SysmlTransitionNode` for both +combined-shape `actionBodyItem` alternatives — `initialNodeMember (actionTargetSuccessionMember)*` +and `(sourceSuccessionMember)? actionBehaviorMember (actionTargetSuccessionMember)*` — reusing the +`MultiNodeCapture` sentinel introduced for the State Transition View fix, so the compact `action +a1; then a2;` idiom and multiple attached successions now resolve correctly (previously both the +node and its successor were silently dropped). `VisitMergeNode`/`VisitDecisionNode`/ +`VisitJoinNode`/`VisitForkNode`/`VisitAcceptNode`/`VisitSendNode` build minimal `SysmlFeatureNode`s +with `FeatureKeyword` values `"merge"`/`"decide"`/`"join"`/`"fork"`/`"accept"`/`"send"`, giving +anonymous control nodes (the dominant idiom in the OMG training corpus) a synthesized `$ +` internal name so their successions still wire correctly. +`ActionFlowViewLayoutStrategy` now renders fork/join as a `LayoutBadge(BadgeShape.HorizontalBar)` +and decision/merge as a `LayoutBadge(BadgeShape.Diamond)`; accept/send keep the rounded-rectangle +box shape (no pentagon primitive exists in the referenced `DemaConsulting.Rendering` package) but +now show their own `Keyword` instead of the hard-coded `"action"` text. Ordinary-action rendering +is unchanged. The CI/CD Pipeline gallery model (`docs/gallery/models/04-pipeline-action-flow.sysml`) +was extended with a named `fork`/`join` pair and the compact succession idiom, and regenerated. + +**Explicitly deferred (separate future items, not delivered here):** a true pentagon accept/send +shape (needs a `DemaConsulting.Rendering` package change); swim-lanes via `LayoutBand`; item-flow +edge annotations (no item-flow/payload capture exists in the AST yet); `assignmentNode`/ +`terminateNode`/`ifNode`/`whileLoopNode`/`forLoopNode` AST support (still entirely unmodeled); an +`actionUsage`-level (rather than `action def`-level) nested `actionBody` — `VisitActionUsage` +still doesn't collect nested action-body children, so control nodes/successions nested inside a +`action x : T { ... }` *usage* (as opposed to an `action def X { ... }` body) remain invisible; +the Sequence View dynamics item below (different subsystem, separate branch). + +### Sequence View dynamics + +- Populate `LayoutActivation` execution bars; combined-fragment boxes (alt/opt/loop); async/reply + message styling. - **Sequence dynamic-view compatibility check (known limitation, carried over from "Dynamic (ad-hoc) views", done):** `DynamicViewSynthesizer`'s `--view-type sequence` pre-check accepts any target with at least one nested `message` usage (the cheap, necessary-but-not-sufficient @@ -96,10 +122,9 @@ and renderers; possibly `LayoutLabel`/compartment tweaks. `DynamicViewSynthesizer` or surfacing `SequenceViewLayoutStrategy`'s own lifeline-resolution result back to the synthesizer. -**Scope:** `SequenceViewLayoutStrategy`, `ActionFlowViewLayoutStrategy`, renderer shape -primitives (bar, diamond, pentagon, note). `LayoutActivation`/`LayoutBand` already defined. -**Visual gate:** sequence shows activation bars + a fragment; action flow shows a fork/join and -a decision/merge with correct shapes. +**Scope:** `SequenceViewLayoutStrategy`, renderer shape primitives (note). `LayoutActivation` +already defined. +**Visual gate:** sequence shows activation bars + a fragment. ### State Transition View: attached-transition states, entry/exit actions, inherited pseudostate features 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 66d9c66e..9cd71d3b 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 @@ -10,9 +10,11 @@ that have no successor, and successions drawn as dashed downward flow arrows. ##### Data Model `ActionFlowViewLayoutStrategy` has no instance state; all input arrives through the `BuildLayout` -parameters. Layout constants (`MinActionWidth`, `CharWidthFactor`, `MarkerSize`, `MarkerBand`) are -declared as `private const double` fields. A private `ActionItem` record carries each action with -its computed box size; successions are carried as `(int From, int To)` index pairs. +parameters. Layout constants (`MinActionWidth`, `CharWidthFactor`, `MarkerSize`, `MarkerBand`, +`ControlNodeBadgeSize`) are declared as `private const double` fields. A private `ActionItem` +record carries each action with its computed box size and a `Kind` field (`"action"` by default, +or `"merge"`/`"decide"`/`"join"`/`"fork"`/`"accept"`/`"send"` for control nodes); successions are +carried as `(int From, int To)` index pairs. ##### Key Methods @@ -39,7 +41,28 @@ by this change — it is applied regardless of specificity. `CollectActions` gat 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. +building a name → index lookup. Both the primary loop and `FindRoot`'s succession/action scoring +recognize a 7-way `IsActionFlowKeyword` allow-list (`"action"`, `"merge"`, `"decide"`, `"join"`, +`"fork"`, `"accept"`, `"send"`) rather than only `"action"`, so control-node-heavy action defs +(e.g. one dominated by fork/join/decide/merge nodes) are still favored as root candidates; each +collected item records its real `Kind` alongside its name. + +###### `ComputeActionSize(name, kind, theme)` and `MakeActionNode(item, rect)` + +`ComputeActionSize` sizes badge-kind items (`IsBadgeKind`: `"merge"`, `"decide"`, `"join"`, +`"fork"`) to a fixed `ControlNodeBadgeSize` square, independent of the name's length; box-kind +items (`"action"`, `"accept"`, `"send"`) keep the existing name-length-driven +`MinActionWidth`/`CharWidthFactor` sizing. `MakeActionNode` is a router that replaces the former +`MakeActionBox`: `"merge"`/`"decide"` map their placed rectangle's centre to a +`LayoutBadge(CentreX, CentreY, Size, BadgeShape.Diamond, Label)`; `"fork"`/`"join"` map to a +`LayoutBadge(..., BadgeShape.HorizontalBar, Label)`; `"accept"`/`"send"` and ordinary `"action"` +items map to an unchanged `LayoutBox(BoxShape.RoundedRectangle, ...)`, except that `"accept"`/ +`"send"` set `Keyword` to their own kind instead of the hard-coded `"action"` text so the +distinct node kind remains visible. For every kind, a badge or box `Label` derived from an +AstBuilder-synthesized `$`-prefixed internal name (anonymous control nodes) is blanked to +`string.Empty`, since synthetic internal identifiers are never meant to be user-visible. The +ordinary-`"action"` rendering path is unchanged byte-for-byte from before this routing was +introduced, since many existing gallery/test fixtures depend on today's plain-box behavior. ###### `ResolveSuccessions(root, index)` diff --git a/docs/design/sysml2-tools-language/semantic/model/ast-builder.md b/docs/design/sysml2-tools-language/semantic/model/ast-builder.md index 64e6fa4d..b5faeda7 100644 --- a/docs/design/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/design/sysml2-tools-language/semantic/model/ast-builder.md @@ -33,6 +33,13 @@ stack with `::` to form the fully-qualified name. | `VisitEntryActionMember` | `EntryActionMemberContext` | `SysmlFeatureNode` (`FeatureKeyword = "entry"`) | | `VisitDoActionMember` | `DoActionMemberContext` | `SysmlFeatureNode` (`FeatureKeyword = "do"`) | | `VisitExitActionMember` | `ExitActionMemberContext` | `SysmlFeatureNode` (`FeatureKeyword = "exit"`) | +| `VisitActionBodyItem` | `ActionBodyItemContext` | `SysmlNode` or `MultiNodeCapture` (node + successions) | +| `VisitMergeNode` | `MergeNodeContext` | `SysmlFeatureNode` (`FeatureKeyword = "merge"`) | +| `VisitDecisionNode` | `DecisionNodeContext` | `SysmlFeatureNode` (`FeatureKeyword = "decide"`) | +| `VisitJoinNode` | `JoinNodeContext` | `SysmlFeatureNode` (`FeatureKeyword = "join"`) | +| `VisitForkNode` | `ForkNodeContext` | `SysmlFeatureNode` (`FeatureKeyword = "fork"`) | +| `VisitAcceptNode` | `AcceptNodeContext` | `SysmlFeatureNode` (`FeatureKeyword = "accept"`) | +| `VisitSendNode` | `SendNodeContext` | `SysmlFeatureNode` (`FeatureKeyword = "send"`) | `GetDeclaredName(IdentificationContext)` handles the three grammar alternatives: @@ -304,6 +311,100 @@ the resulting feature node still registers as an (unnamed, unregistered) AST chi information is lost, but it is not itself a resolvable symbol. This scope boundary is intentional and matches the ROADMAP's framing of entry/do/exit action support as "minimal, non-behavioral." +**Attached-succession action bodies and control-node features.** The `actionBodyItem` grammar +rule has an analogous combined-shape problem to `stateBodyItem`: two of its alternatives attach +a succession directly onto the immediately preceding node within the same alternative rather than +exposing it as a separate `successionAsUsage` — +`initialNodeMember (actionTargetSuccessionMember)*` (e.g. `first start; then off;`) and +`(sourceSuccessionMember)? actionBehaviorMember (actionTargetSuccessionMember)*` (e.g. the compact +`action a1; then a2;` idiom). Before `VisitActionBodyItem` existed, `AstBuilder` only ever +visited the leading node of each alternative and silently dropped every attached succession, so +the compact idiom resolved both action nodes but produced no succession edge linking them. + +`VisitActionBodyItem` dispatches all four alternatives explicitly: + +- `nonBehaviorBodyItem`, `guardedSuccessionMember` — passed straight through to `Visit(...)` (no + attached-succession shape applies). +- `initialNodeMember (actionTargetSuccessionMember)*` — when one or more + `actionTargetSuccessionMember`s are attached, synthesizes a `SysmlTransitionNode` per entry + (via `BuildActionTargetSuccession`) sourced from the `qualifiedName` referenced by the + `initialNodeMember`; the bare `first start;` form (no attached succession) remains a no-op, + unchanged from today, since `ActionFlowViewLayoutStrategy` infers start/done markers from + succession topology rather than a declarative initial-marker concept. +- `(sourceSuccessionMember)? actionBehaviorMember (actionTargetSuccessionMember)*` — visits + `actionBehaviorMember` (which delegates to the existing `actionNodeMember`/`behaviorUsageMember` + handling) to obtain the main node, then calls `BuildActionTargetSuccession` once per + `actionTargetSuccessionMember` entry, each producing a `SysmlTransitionNode` whose `Source` is + the main node's `Name`. When the optional leading `sourceSuccessionMember` is present (a bare + `then` immediately before the node, e.g. `action a; then fork f; ...` — the dominant real-world + idiom for wiring a control node into a flow) an additional *incoming* `SysmlTransitionNode` is + prepended, whose `Source` is `_actionBodyPreviousNodeName` and whose `Target` is the main node's + `Name`. The grammar's leading marker (`sourceSuccessionMember: THEN sourceSuccession`, where + `sourceSuccession`/`sourceEndMember`/`sourceEnd` carry no name token at all) is a pure marker — + its meaning is "this node's incoming edge comes from whatever immediately preceded it in the + same enclosing action body" — so its `Source` identity cannot be read off the grammar node + itself. It is instead resolved from `_actionBodyPreviousNodeName`, an `AstBuilder` instance + field maintained by `CollectActionBodyChildren` (a body-specific counterpart to the generic + `CollectChildren`, used only for action bodies) as it iterates an action body's + `actionBodyItem`s in source order, updating the tracked position after each item via + `DetermineFlowPositionName` (which resolves to the last synthesized transition's `Target` when + the item produced trailing successions, or the visited node's own `Name` otherwise). When no + previous position is known (e.g. the item is the first thing in the body), no incoming edge is + synthesized rather than fabricating a `Source` from nothing — this matches the safe, + no-op-by-default behavior applied elsewhere in this visitor. `CollectChildren` itself remains + untouched and continues to serve the state-body call sites, since only action bodies need this + order-sensitive bookkeeping. + +`BuildActionTargetSuccession` handles all three `actionTargetSuccession` grammar forms: +unguarded `targetSuccession` (`sourceEndMember THEN connectorEndMember`), guarded +`guardedTargetSuccession` (`if guardExpressionMember then connectorEndMember`, capturing the +guard's expression text), and `defaultTargetSuccession` (`else then connectorEndMember`, which the +grammar provides no guard expression for). + +When one or more attached successions (incoming, trailing, or both) are produced, +`VisitActionBodyItem` returns the same `MultiNodeCapture` sentinel used by +`VisitStateBodyItem`, wrapping the incoming transition (if any), the node, and its trailing +succession(s) in that order; when none are produced, it returns the visited node/pass-through +result directly. + +**Known follow-up gaps (out of scope for this fix).** Two structurally analogous +leading-marker cases were discovered while fixing the above but deliberately left unfixed to keep +this change surgical: + +- `nonBehaviorBodyItem`'s `(sourceSuccessionMember)? structureUsageMember` shape has the same + "leading `then` implies an implicit incoming edge" grammar structure, but there is no + `VisitNonBehaviorBodyItem`/`VisitStructureUsageMember` override at all today — it relies + entirely on ANTLR's default `VisitChildren` aggregation, which drops every child but the last. + This is a broader, pre-existing gap (no `MultiNodeCapture` handling exists there yet at all), + not merely a missing `sourceSuccessionMember` read. +- `VisitStateBodyItem`'s `(sourceSuccessionMember)? behaviorUsageMember + (targetTransitionUsageMember)*` shape (State Transition View) has the identical unread-marker + problem this fix addresses for action bodies, and was not established as a working precedent to + copy — it has the same gap, latent and undetected because no current test exercises a leading + `then` before a `behaviorUsageMember` inside a state body. + +`VisitMergeNode`, `VisitDecisionNode`, `VisitJoinNode`, `VisitForkNode`, `VisitAcceptNode`, and +`VisitSendNode` each delegate to a shared `BuildActionNodeFeature(usage, keyword)` helper that +builds a **minimal** `SysmlFeatureNode` — `FeatureKeyword` set to +`"merge"`/`"decide"`/`"join"`/`"fork"`/`"accept"`/`"send"` respectively, `Children` always empty. +Unlike ordinary anonymous actions (which are left nameless), an anonymous control node is given a +synthesized internal name of the form `$` (via a monotonically increasing +`_anonymousNodeCounter` field) rather than `null`. This is a deliberate deviation from the +State Transition View precedent: anonymous fork/decide/send is the *dominant* real-world idiom in +the OMG training corpus (e.g. `then fork;` immediately followed by several `then` successions), +so leaving these nodes nameless would make it impossible to wire their successions or render a +distinct badge for them. The synthetic name is never registered in the symbol table +(`QualifiedName` stays `null`, so `$`-prefixed names never resolve and surface only as cosmetic +"unresolved reference" warnings) and is blanked from rendered labels by +`ActionFlowViewLayoutStrategy`; it exists purely as an internal succession-wiring mechanism. When +the control node instead has an *explicitly declared* name (e.g. `fork buildFork;`), it is treated +like any other named feature: `QualifiedName` is populated via the same `QualifyName` helper used +by `BuildStateActionFeatureNode`, so it is registered in the symbol table and correctly subject to +expose-scope filtering (`ExposeScopeResolver.IsInSubjectScope`) in +`ActionFlowViewLayoutStrategy.CollectActions`. +`assignmentNode`, `terminateNode`, `ifNode`, `whileLoopNode`, and `forLoopNode` remain +intentionally unhandled — a pre-existing gap, not introduced by this change. + ##### Error Handling Anonymous elements (null declared names) are silently skipped — visitor methods return `null` diff --git a/docs/gallery/README.md b/docs/gallery/README.md index 440d40c4..6570ac1c 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -113,8 +113,12 @@ SVG: [`svg/ElevatorStateTransitionView.svg`](svg/ElevatorStateTransitionView.svg ## 4. Action Flow View — CI/CD Pipeline -Shows actions arranged top-to-bottom by the layered layout pipeline, -with a start node, a done node, and a quality-gate branch and join. +Shows actions arranged top-to-bottom by the layered layout pipeline, with a start node, a done +node, and a quality-gate branch and join. The `build` step fans out through a named `fork` +(rendered as a horizontal-bar badge) into parallel `unitTest`/`securityScan` branches that rejoin +through a named `join` (also a horizontal-bar badge) before `qualityGate`. The `checkout` step +uses the compact `action checkout; then restoreDependencies;` idiom instead of a standalone +`first ... then ...;` succession statement. Model: [`models/04-pipeline-action-flow.sysml`](models/04-pipeline-action-flow.sysml) · SVG: [`svg/PipelineActionFlowView.svg`](svg/PipelineActionFlowView.svg) diff --git a/docs/gallery/models/04-pipeline-action-flow.sysml b/docs/gallery/models/04-pipeline-action-flow.sysml index 2a9f5559..14b7dbf3 100644 --- a/docs/gallery/models/04-pipeline-action-flow.sysml +++ b/docs/gallery/models/04-pipeline-action-flow.sysml @@ -3,11 +3,29 @@ package CiCdPipeline { // A continuous-integration / deployment action flow with a quality gate branch. action def Pipeline { action checkout; + then restoreDependencies; + action restoreDependencies; action build; + first restoreDependencies then build; + first build then buildFork; + + fork buildFork; + then unitTest; + then securityScan; + action unitTest; + then integrationTest; + action integrationTest; + then testJoin; + action securityScan; + then testJoin; + + join testJoin; + then qualityGate; + action qualityGate; action deployStaging; action smokeTest; @@ -16,13 +34,6 @@ package CiCdPipeline { action notifySuccess; action notifyFailure; - first checkout then restoreDependencies; - first restoreDependencies then build; - first build then unitTest; - first build then securityScan; - first unitTest then integrationTest; - first integrationTest then qualityGate; - first securityScan then qualityGate; first qualityGate then deployStaging; first deployStaging then smokeTest; first smokeTest then deployProduction; diff --git a/docs/gallery/png/PipelineActionFlowView.png b/docs/gallery/png/PipelineActionFlowView.png index ebe7b338..ee1fa123 100644 Binary files a/docs/gallery/png/PipelineActionFlowView.png and b/docs/gallery/png/PipelineActionFlowView.png differ diff --git a/docs/gallery/svg/PipelineActionFlowView.svg b/docs/gallery/svg/PipelineActionFlowView.svg index 51d910f7..432ad46f 100644 --- a/docs/gallery/svg/PipelineActionFlowView.svg +++ b/docs/gallery/svg/PipelineActionFlowView.svg @@ -1,4 +1,4 @@ - + @@ -30,62 +30,68 @@ - - «action» - checkout - - «action» - restoreDependencies - - «action» - build - - «action» - unitTest - - «action» - integrationTest - - «action» - securityScan - - «action» - qualityGate - - «action» - deployStaging - - «action» - smokeTest - - «action» - deployProduction - - «action» - rollback - - «action» - notifySuccess - - «action» - notifyFailure - - - - - - - - - - - - - - - - - - - + + «action» + checkout + + «action» + restoreDependencies + + «action» + build + + buildFork + + «action» + unitTest + + «action» + integrationTest + + «action» + securityScan + + testJoin + + «action» + qualityGate + + «action» + deployStaging + + «action» + smokeTest + + «action» + deployProduction + + «action» + rollback + + «action» + notifySuccess + + «action» + notifyFailure + + + + + + + + + + + + + + + + + + + + + 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 e7ef696e..ffc9062a 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 @@ -148,3 +148,30 @@ sections: defeating the intended scoping. tests: - ActionFlowView_BuildLayout_ExposeInnerActionOfNestedDefinition_SelectsNestedDefinitionNotAncestor + + - id: SysML2Tools-Core-Layout-Internal-ActionFlowViewLayoutStrategy-ControlNodeShapes + title: >- + ActionFlowViewLayoutStrategy shall render "fork"/"join" action nodes as a + LayoutBadge with BadgeShape.HorizontalBar, "decide"/"merge" action nodes as a + LayoutBadge with BadgeShape.Diamond, and "accept"/"send" action nodes as a + LayoutBox with BoxShape.RoundedRectangle whose Keyword reflects the distinct + "accept"/"send" node kind, while leaving ordinary "action" node rendering + unchanged. + justification: | + Control nodes are semantically distinct from ordinary actions and the existing + State Transition View precedent already separates pseudostate markers from real + state boxes; Action Flow View needs the analogous visual separation so fork/join + branch points and decision/merge points are recognizable at a glance. No pentagon + primitive exists in the referenced rendering package, so accept/send nodes keep the + rounded-rectangle box shape but gain a distinguishing Keyword label instead of the + hard-coded "action" text. Badge-kind nodes use a fixed intrinsic size + (ControlNodeBadgeSize) rather than name-based sizing, and their rendered Label is + blanked when the underlying name is an AstBuilder-synthesized internal identifier. + Ordinary-action rendering is required to stay byte-for-byte unchanged, since many + existing gallery/test fixtures depend on today's plain-box behavior. + tests: + - ActionFlowView_BuildLayout_ForkAndJoin_RenderHorizontalBarBadges + - ActionFlowView_BuildLayout_DecisionAndMerge_RenderDiamondBadges + - ActionFlowView_BuildLayout_AcceptAndSend_RenderBoxesWithDistinctKeyword + - ActionFlowView_BuildLayout_SyntheticControlNodeName_RendersBlankLabel + - ActionFlowView_BuildLayout_CompactActionThenIdiom_ResolvesBothNodes diff --git a/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml b/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml index 3b8865f8..64a01e99 100644 --- a/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml +++ b/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml @@ -272,3 +272,68 @@ sections: - WorkspaceLoader_LoadAsync_UnnamedEntryActionReferenceForm_NoNameNoCrash - WorkspaceLoader_LoadAsync_StateUsageWithExplicitTyping_RecordsTypingEdge - Transition_OmgCorpusFixture_ResolvesAllStatesAndTransitions + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-ActionBodyAttachedSuccession + title: >- + AstBuilder shall synthesize a SysmlTransitionNode for each attached-succession + `actionBodyItem` shape — an `initialNodeMember` followed by zero or more + `actionTargetSuccessionMember`s, and an optional leading `sourceSuccessionMember` + plus an `actionBehaviorMember` followed by zero or more + `actionTargetSuccessionMember`s — in addition to building the preceding/following + node itself. Trailing successions are sourced from the node's own name; the + optional leading `sourceSuccessionMember` (a bare `then` immediately before an + `actionBehaviorMember`, e.g. `action a; then fork f;`) synthesizes an implicit + *incoming* succession whose `Source` is the name of the immediately preceding + sibling `actionBodyItem` in the same enclosing action body, since the grammar's + leading marker itself carries no name. + justification: | + SysML v2's grammar attaches a `then`/guarded/default succession directly onto the + immediately preceding action node or `first` marker within the same + `actionBodyItem` alternative, rather than as a standalone + `successionAsUsage`/`transitionUsageMember`. Previously, AstBuilder only visited the + leading node and silently dropped every attached succession, so the compact idiom + `action a1; then a2;` produced both nodes but no succession edge between them. The + existing `MultiNodeCapture` sentinel (introduced for the analogous State Transition + View fix) is reused so a single `actionBodyItem` visit can yield both the node and + one or more synthesized transition nodes. A bare `initialNodeMember` with no + attached succession remains a no-op, unchanged from today, since + `ActionFlowViewLayoutStrategy` infers start/done markers from succession topology + rather than a declarative initial-marker concept. Additionally, the grammar's + `(sourceSuccessionMember)? actionBehaviorMember` shape's optional leading marker — + the dominant real-world idiom for wiring a control node into a flow, e.g. + `action TurnOn; then fork; then monitorBrakePedal; ...` — previously went entirely + unread, so the fork/decide/merge/join/accept/send node's *incoming* edge from the + preceding sibling was silently dropped even though its outgoing successions worked. + `CollectActionBodyChildren` now tracks the current flow position (the preceding + sibling's name) in source order as it iterates an action body's items, so + `VisitActionBodyItem` can resolve that implicit incoming `Source` when a leading + marker is present. + tests: + - WorkspaceLoader_LoadAsync_CompactActionThenIdiom_ResolvesBothNodes + - WorkspaceLoader_LoadAsync_MultipleActionTargetSuccessions_CapturesAll + - WorkspaceLoader_LoadAsync_BareInitialNodeMember_ProducesNoSuccession + - WorkspaceLoader_LoadAsync_InitialNodeMemberWithAttachedSuccession_SynthesizesTransition + - WorkspaceLoader_LoadAsync_GuardedAndDefaultActionTargetSuccession_ExtractTargets + - ControlNode_OmgCorpusFixture_ResolvesForkJoinDecisionMerge + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-ActionControlNodes + title: >- + AstBuilder shall build a minimal SysmlFeatureNode (with Children always empty and + FeatureKeyword "merge", "decide", "join", "fork", "accept", or "send") for each + mergeNode, decisionNode, joinNode, forkNode, acceptNode, and sendNode, synthesizing + an internal `$` name when the node is anonymous (no declared name), so + it can still act as the implicit source of its attached successions. + justification: | + Anonymous control nodes (e.g. a bare `then fork;` immediately followed by several + `then` successions) are the dominant idiom for fork/decide/send in the real OMG + training corpus, not a rare edge case; leaving them nameless (as ordinary anonymous + actions are) would make it impossible to wire their successions or render a distinct + badge for them. The synthetic name is never added to the symbol table (QualifiedName + stays null) and is blanked from rendered labels, so it is purely an internal wiring + mechanism. `assignmentNode`, `terminateNode`, `ifNode`, `whileLoopNode`, and + `forLoopNode` remain intentionally unhandled — a pre-existing gap, not introduced by + this change. + tests: + - WorkspaceLoader_LoadAsync_AnonymousControlNodes_SynthesizeNames + - WorkspaceLoader_LoadAsync_NamedControlNodes_KeepDeclaredName + - ControlNode_OmgCorpusFixture_ResolvesForkJoinDecisionMerge 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 f9913aeb..53368656 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 @@ -65,3 +65,8 @@ configuration are required beyond a standard .NET SDK installation. | `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 | +| `ActionFlowView_BuildLayout_ForkAndJoin_RenderHorizontalBarBadges` | Fork/join render horizontal-bar badges | +| `ActionFlowView_BuildLayout_DecisionAndMerge_RenderDiamondBadges` | Decision/merge render diamond badges | +| `ActionFlowView_BuildLayout_AcceptAndSend_RenderBoxesWithDistinctKeyword` | Accept/send keep own `Keyword` | +| `ActionFlowView_BuildLayout_SyntheticControlNodeName_RendersBlankLabel` | `$`-prefixed synthetic names render blank | +| `ActionFlowView_BuildLayout_CompactActionThenIdiom_ResolvesBothNodes` | Compact `action a; then b;` idiom resolves | diff --git a/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md b/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md index cd92c637..bb3531a8 100644 --- a/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md @@ -112,3 +112,11 @@ external services or additional configuration are required beyond a standard .NE | State usage explicit typing | `WorkspaceLoader_LoadAsync_StateUsageWithExplicitTyping_RecordsTypingEdge` | | Transition source resolves | `WorkspaceLoader_LoadAsync_TransitionSourceStartFeature_ResolvesToStdlibActionMember` | | OMG corpus fixture | `Transition_OmgCorpusFixture_ResolvesAllStatesAndTransitions` | +| Compact `action a; then b;` idiom | `WorkspaceLoader_LoadAsync_CompactActionThenIdiom_ResolvesBothNodes` | +| Target successions + incoming edge | `WorkspaceLoader_LoadAsync_MultipleActionTargetSuccessions_CapturesAll` | +| Bare `first start;`, no succession | `WorkspaceLoader_LoadAsync_BareInitialNodeMember_ProducesNoSuccession` | +| Attached first-then edge | `WorkspaceLoader_LoadAsync_InitialNodeMemberWithAttachedSuccession_SynthesizesTransition` | +| Anonymous control nodes synthesize names | `WorkspaceLoader_LoadAsync_AnonymousControlNodes_SynthesizeNames` | +| Named control nodes keep declared name | `WorkspaceLoader_LoadAsync_NamedControlNodes_KeepDeclaredName` | +| Guarded/default succession | `WorkspaceLoader_LoadAsync_GuardedAndDefaultActionTargetSuccession_ExtractTargets` | +| Fork/join/decision/merge OMG fixtures + incoming | `ControlNode_OmgCorpusFixture_ResolvesForkJoinDecisionMerge` | diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs index 8aa2a8d1..43c034af 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/ActionFlowViewLayoutStrategy.cs @@ -35,6 +35,13 @@ internal sealed class ActionFlowViewLayoutStrategy : ILayoutStrategy /// Diameter of the start and done markers. private const double MarkerSize = 20.0; + /// + /// Intrinsic size (both width and height) of a control-node badge (fork/join bar, decision/merge + /// diamond), independent of the box-sizing heuristics used for ordinary actions and + /// accept/send boxes. + /// + private const double ControlNodeBadgeSize = 24.0; + /// /// Gap between a start/done marker and the adjacent action layer. Mirrors the layered algorithm's /// between-layer corridor width (70 px) so the control markers keep the same vertical rhythm as @@ -42,8 +49,14 @@ internal sealed class ActionFlowViewLayoutStrategy : ILayoutStrategy /// private const double MarkerLayerGap = 70.0; - /// An action with its computed box size. - private sealed record ActionItem(string Name, double Width, double Height); + /// + /// An action-flow node with its computed intrinsic size and kind. is one + /// of "action" (ordinary action box, or a succession-endpoint-only fallback — the default), + /// "merge"/"decide"/"join"/"fork" (rendered as a badge), or + /// "accept"/"send" (rendered as a rounded-rectangle box, like an ordinary action, + /// but labelled with its own distinct keyword). + /// + private sealed record ActionItem(string Name, double Width, double Height, string Kind = "action"); /// public LayoutTree BuildLayout(ViewContext context, RenderOptions options) @@ -102,7 +115,7 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) var nodes = new List(); for (var i = 0; i < actions.Count; i++) { - nodes.Add(MakeActionBox(actions[i], rects[i])); + nodes.Add(MakeActionNode(actions[i], rects[i])); } var crossings = AddSuccessionEdges(edges, placed.EdgePolylines, rects, offsetX, offsetY, nodes); @@ -151,7 +164,7 @@ public LayoutTree BuildLayout(ViewContext context, RenderOptions options) } var successions = def.Children.OfType().Count(); - var actions = def.Children.OfType().Count(f => f.FeatureKeyword == "action"); + var actions = def.Children.OfType().Count(f => IsActionFlowKeyword(f.FeatureKeyword)); var score = (successions * 100) + actions; var scoreBetter = score > bestScore; var isBetter = scope is not null @@ -185,7 +198,7 @@ private static (IReadOnlyList Actions, Dictionary Index var actions = new List(); var index = new Dictionary(StringComparer.Ordinal); - void Add(string name) + void Add(string name, string kind) { if (index.ContainsKey(name)) { @@ -193,13 +206,13 @@ void Add(string name) } index[name] = actions.Count; - var (width, height) = ComputeActionSize(name, theme); - actions.Add(new ActionItem(name, width, height)); + var (width, height) = ComputeActionSize(name, kind, theme); + actions.Add(new ActionItem(name, width, height, kind)); } foreach (var feature in root.Children.OfType()) { - if (feature.FeatureKeyword != "action" || feature.Name is null) + if (!IsActionFlowKeyword(feature.FeatureKeyword) || feature.Name is null) { continue; } @@ -210,19 +223,19 @@ void Add(string name) continue; } - Add(feature.Name); + Add(feature.Name, feature.FeatureKeyword); } foreach (var succession in root.Children.OfType()) { if (LastSegment(succession.Source) is { } s) { - Add(s); + Add(s, "action"); } if (LastSegment(succession.Target) is { } t) { - Add(t); + Add(t, "action"); } } @@ -247,28 +260,89 @@ void Add(string name) return result; } - /// Computes the intrinsic size of an action box. - private static (double Width, double Height) ComputeActionSize(string name, Theme theme) + /// Computes the intrinsic size of an action-flow node for the given kind. + /// + /// Ordinary actions (kind "action") and accept/send boxes use the same name-based sizing + /// heuristic as before this branch (accept/send previously always used kind "action" + /// sizing too, so this is unchanged for them). Badge kinds (fork/join/decide/merge) use a fixed + /// square instead, independent of the node's name length. + /// + private static (double Width, double Height) ComputeActionSize(string name, string kind, Theme theme) { + if (IsBadgeKind(kind)) + { + return (ControlNodeBadgeSize, ControlNodeBadgeSize); + } + var labelWidth = (name.Length * theme.FontSizeTitle * CharWidthFactor) + (4.0 * theme.LabelPadding); var width = Math.Max(MinActionWidth, labelWidth); var height = BoxMetrics.TitleAreaHeight(theme, hasLabel: true, hasKeyword: true) + theme.LabelPadding; return (width, height); } - /// Creates a rounded-rectangle action box at the given position. - private static LayoutBox MakeActionBox(ActionItem action, Rect rect) => - new( - X: rect.X, - Y: rect.Y, - Width: rect.Width, - Height: rect.Height, - Label: action.Name, - Depth: 1, - Shape: BoxShape.RoundedRectangle, - Compartments: [], - Children: [], - Keyword: "action"); + /// + /// Returns whether names an action-flow node kind recognized by this + /// layout strategy: the original ordinary "action" plus the control-node/accept/send + /// keywords added for fork/join/decision/merge/accept/send support. + /// + private static bool IsActionFlowKeyword(string keyword) => + keyword is "action" or "merge" or "decide" or "join" or "fork" or "accept" or "send"; + + /// Returns whether renders as a rather than a box. + private static bool IsBadgeKind(string kind) => kind is "merge" or "decide" or "join" or "fork"; + + /// + /// Creates the layout node for an action-flow item at the given placed rectangle: a + /// badge for decision/merge, a + /// badge for fork/join, or a rounded-rectangle box (labelled with its own distinct keyword) for + /// ordinary actions and accept/send nodes. Ordinary-action ("action") rendering is + /// byte-for-byte identical to before this branch. A synthetic $-prefixed internal name + /// (assigned by AstBuilder to an anonymous control node) is never shown as a label. + /// + private static LayoutNode MakeActionNode(ActionItem action, Rect rect) + { + var centreX = rect.X + (rect.Width / 2.0); + var centreY = rect.Y + (rect.Height / 2.0); + var label = action.Name.StartsWith("$", StringComparison.Ordinal) ? null : action.Name; + + switch (action.Kind) + { + case "merge": + case "decide": + return new LayoutBadge(centreX, centreY, rect.Width, BadgeShape.Diamond, label); + + case "fork": + case "join": + return new LayoutBadge(centreX, centreY, rect.Width, BadgeShape.HorizontalBar, label); + + case "accept": + case "send": + return new LayoutBox( + X: rect.X, + Y: rect.Y, + Width: rect.Width, + Height: rect.Height, + Label: label ?? string.Empty, + Depth: 1, + Shape: BoxShape.RoundedRectangle, + Compartments: [], + Children: [], + Keyword: action.Kind); + + default: + return new LayoutBox( + X: rect.X, + Y: rect.Y, + Width: rect.Width, + Height: rect.Height, + Label: action.Name, + Depth: 1, + Shape: BoxShape.RoundedRectangle, + Compartments: [], + Children: [], + Keyword: "action"); + } + } /// /// Adds the succession flow edges (top-to-bottom) between action boxes, using the orthogonal diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs index f20ab96f..744e2d27 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs @@ -250,10 +250,13 @@ private sealed class AnnotationCapture : SysmlNode /// generic ANTLR Visit pipeline from a single grammar alternative that actually /// produces several sibling AST nodes (e.g. a stateBodyItem's attached-transition /// shapes: the preceding state/entry-action usage plus one - /// per attached transition). Never appears in a real - /// list — always intercepts it and flattens its - /// into the owning node's children instead, mirroring how - /// is intercepted and routed into + /// per attached transition; or an actionBodyItem's analogous combined-succession + /// shapes: (sourceSuccessionMember)? actionBehaviorMember + /// (actionTargetSuccessionMember)* and initialNodeMember + /// (actionTargetSuccessionMember)*). Never appears in a real + /// list — always intercepts + /// it and flattens its into the owning node's children instead, + /// mirroring how is intercepted and routed into /// rather than becoming a child itself. /// private sealed class MultiNodeCapture : SysmlNode @@ -261,6 +264,34 @@ private sealed class MultiNodeCapture : SysmlNode public required IReadOnlyList Nodes { get; init; } } + /// + /// Monotonically-increasing counter used to synthesize a unique internal + /// $<keyword><n> name for an anonymous control/accept/send node (see + /// ). A synthetic name is required (rather than leaving + /// null, as plain anonymous actions do) because anonymous + /// fork/decide/send nodes are the dominant idiom in the real OMG + /// corpus and must still render as a distinct shape and act as the implicit + /// of their attached successions. + /// + private int _anonymousNodeCounter; + + /// + /// Tracks the name of the most recently established "flow position" while + /// iterates an action body's actionBodyItems + /// in source order, so can resolve the implicit + /// Source of a leading bare then (sourceSuccessionMember) on a + /// subsequent sibling. The grammar's sourceSuccessionMember: THEN sourceSuccession + /// carries no name at all (it is just the THEN token plus an always-empty + /// sourceEnd) — its meaning is "this node's incoming edge comes from whatever + /// action/node immediately precedes it in the same enclosing action body." That identity + /// can only come from order-sensitive traversal state maintained by the caller that + /// iterates the sibling actionBodyItems, not from anything inside the grammar node + /// itself. Null outside an active call, or when the + /// preceding sibling did not establish a nameable flow position (e.g. the very first item + /// in the body). + /// + private string? _actionBodyPreviousNodeName; + /// public override SysmlNode? VisitPartDefinition(SysMLv2Parser.PartDefinitionContext context) { @@ -352,9 +383,12 @@ private sealed class MultiNodeCapture : SysmlNode var qualifiedName = QualifyName(name); var supertypeNames = GetSubclassificationSupertypes(decl?.subclassificationPart()); - // Collect the action body (action usages and successions) as children. + // Collect the action body (action usages and successions) as children. Action bodies use + // CollectActionBodyChildren (not the generic CollectChildren) because resolving an + // implicit leading `then` requires order-sensitive tracking of the preceding sibling's + // flow position — see VisitActionBodyItem and _actionBodyPreviousNodeName. _namespaceStack.Add(name); - var (children, annotations) = CollectChildren(context.actionBody()?.actionBodyItem() ?? []); + var (children, annotations) = CollectActionBodyChildren(context.actionBody()?.actionBodyItem() ?? []); _namespaceStack.RemoveAt(_namespaceStack.Count - 1); return new SysmlDefinitionNode @@ -409,6 +443,229 @@ private sealed class MultiNodeCapture : SysmlNode }; } + /// + /// Dispatches a single actionBodyItem alternative to the appropriate builder logic. + /// Mirrors 's dispatch shape. nonBehaviorBodyItem and + /// guardedSuccessionMember already produce exactly one AST node via default ANTLR + /// visitor dispatch, so they are passed through unchanged (the latter's own internal shape + /// is left untouched here, exactly as leaves its own + /// untouched alternatives unhandled). The two combined-succession shapes — + /// initialNodeMember (actionTargetSuccessionMember)* (e.g. first start; or + /// first start then off;) and (sourceSuccessionMember)? actionBehaviorMember + /// (actionTargetSuccessionMember)* (e.g. the compact action a1; then a2; idiom) — + /// each may produce more than one sibling node (the referenced/behavior node itself, plus + /// one per attached target succession whose + /// Source is implicit); without this override, ANTLR's default VisitChildren + /// aggregation silently discards every child result but the last, dropping the earlier + /// nodes and losing successions entirely. The actionBehaviorMember alternative's + /// optional leading sourceSuccessionMember (a bare then immediately before + /// the node, e.g. action a; then fork f; ...) also synthesizes an additional + /// incoming whose Source is + /// — the name of the immediately preceding + /// sibling in the enclosing action body, tracked by + /// since the grammar's leading marker itself carries no identity. + /// + public override SysmlNode? VisitActionBodyItem(SysMLv2Parser.ActionBodyItemContext context) + { + if (context.nonBehaviorBodyItem() is { } nonBehaviorBodyItem) + { + return Visit(nonBehaviorBodyItem); + } + + if (context.guardedSuccessionMember() is { } guardedSuccessionMember) + { + return Visit(guardedSuccessionMember); + } + + if (context.initialNodeMember() is { } initialNodeMember) + { + var targets = context.actionTargetSuccessionMember(); + if (targets.Length == 0) + { + // Bare `first start;` carries no attached succession. Unlike state-transition + // pseudostates, ActionFlowViewLayoutStrategy infers its start/done markers purely + // from succession topology (no declarative initial-marker concept exists for + // actions), so there is nothing useful to synthesize here — unchanged from today. + return null; + } + + var sourceName = initialNodeMember.qualifiedName()?.GetText(); + var nodes = targets + .Select(target => (SysmlNode)BuildActionTargetSuccession(sourceName, target.actionTargetSuccession())) + .ToList(); + + return nodes.Count == 1 ? nodes[0] : new MultiNodeCapture { Nodes = nodes }; + } + + if (context.actionBehaviorMember() is { } actionBehaviorMember) + { + var behaviorNode = Visit(actionBehaviorMember); + if (behaviorNode is null) + { + return null; + } + + var targets = context.actionTargetSuccessionMember(); + var hasImplicitSource = context.sourceSuccessionMember() is not null; + if (!hasImplicitSource && targets.Length == 0) + { + return behaviorNode; + } + + var nodes = new List(); + if (hasImplicitSource && _actionBodyPreviousNodeName is { } previousName) + { + // A bare leading `then` (sourceSuccessionMember) means this node's incoming edge + // comes from whatever immediately preceded it in the same enclosing action body. + // The grammar gives that marker no name, so the source is resolved from the + // order-sensitive _actionBodyPreviousNodeName tracked by CollectActionBodyChildren. + // When no previous position is known (e.g. this is the first item in the body), no + // incoming edge is synthesized rather than fabricating a Source from nothing. + nodes.Add(new SysmlTransitionNode + { + Source = previousName, + Target = behaviorNode.Name, + }); + } + + nodes.Add(behaviorNode); + + var sourceName = behaviorNode.Name; + foreach (var target in targets) + { + nodes.Add(BuildActionTargetSuccession(sourceName, target.actionTargetSuccession())); + } + + return nodes.Count == 1 ? nodes[0] : new MultiNodeCapture { Nodes = nodes }; + } + + return null; + } + + /// + /// Builds the for one actionTargetSuccession + /// attached after an action-flow node (e.g. then a2;, if g then a3;, or + /// else a4;), whose Source is implicitly the preceding node's name (never + /// present in the grammar itself). Covers all three alternatives: targetSuccession + /// (unguarded, target via a bare ), + /// guardedTargetSuccession (if ... then ..., guard captured), and + /// defaultTargetSuccession (else ..., no guard expression exists in the + /// grammar for this alternative — a known, documented simplification). The trailing + /// usageBody() is ignored, mirroring 's + /// treatment of targetTransitionUsage's own trailing actionBody(). + /// + private SysmlTransitionNode BuildActionTargetSuccession( + string? sourceName, + SysMLv2Parser.ActionTargetSuccessionContext? succession) + { + if (succession?.targetSuccession() is { } targetSuccession) + { + return new SysmlTransitionNode + { + Source = sourceName, + Target = ConnectorEndReference(targetSuccession.connectorEndMember()), + }; + } + + if (succession?.guardedTargetSuccession() is { } guardedTargetSuccession) + { + return new SysmlTransitionNode + { + Source = sourceName, + Target = ConnectorEndReference( + guardedTargetSuccession.transitionSuccessionMember()?.transitionSuccession()?.connectorEndMember()), + Guard = guardedTargetSuccession.guardExpressionMember()?.ownedExpression()?.GetText(), + }; + } + + if (succession?.defaultTargetSuccession() is { } defaultTargetSuccession) + { + return new SysmlTransitionNode + { + Source = sourceName, + Target = ConnectorEndReference( + defaultTargetSuccession.transitionSuccessionMember()?.transitionSuccession()?.connectorEndMember()), + }; + } + + return new SysmlTransitionNode { Source = sourceName, Target = null }; + } + + /// + public override SysmlNode? VisitMergeNode(SysMLv2Parser.MergeNodeContext context) + { + return BuildActionNodeFeature(context.usageDeclaration(), "merge"); + } + + /// + public override SysmlNode? VisitDecisionNode(SysMLv2Parser.DecisionNodeContext context) + { + return BuildActionNodeFeature(context.usageDeclaration(), "decide"); + } + + /// + public override SysmlNode? VisitJoinNode(SysMLv2Parser.JoinNodeContext context) + { + return BuildActionNodeFeature(context.usageDeclaration(), "join"); + } + + /// + public override SysmlNode? VisitForkNode(SysMLv2Parser.ForkNodeContext context) + { + return BuildActionNodeFeature(context.usageDeclaration(), "fork"); + } + + /// + public override SysmlNode? VisitAcceptNode(SysMLv2Parser.AcceptNodeContext context) + { + var declaration = context.acceptNodeDeclaration()?.actionNodeUsageDeclaration(); + return BuildActionNodeFeature(declaration?.usageDeclaration(), "accept"); + } + + /// + public override SysmlNode? VisitSendNode(SysMLv2Parser.SendNodeContext context) + { + var declaration = context.actionNodeUsageDeclaration()?.usageDeclaration() + ?? context.actionUsageDeclaration()?.usageDeclaration(); + return BuildActionNodeFeature(declaration, "send"); + } + + /// + /// Builds a minimal, deliberately non-behavioral for a + /// merge/decide/join/fork/accept/send action-flow + /// control node, registering only its (possibly synthesized) name with no children. When + /// yields no declared name — the dominant real-world idiom for + /// fork/decide/send per the OMG training corpus (e.g. then fork; + /// immediately followed by several then <name>; successions) — a synthetic + /// $<keyword><n> name is assigned via + /// so the node still renders as a distinct shape and can still act as those successions' + /// implicit source; its stays + /// in that synthesized case (this is purely local succession-wiring data, never registered + /// in the symbol table or referenced across files/scopes). When the node instead has an + /// explicitly declared name (e.g. fork buildFork;), it is treated like any other + /// named feature in this file: its is populated via + /// so it is registered in the symbol table and correctly subject + /// to expose-scope filtering, mirroring . The + /// nested actionBody's internal semantics are deliberately NOT modeled, mirroring + /// . assignmentNode/terminateNode/ + /// ifNode/whileLoopNode/forLoopNode remain an intentional, out-of-scope + /// gap — not handled here or anywhere else in . + /// + private SysmlFeatureNode BuildActionNodeFeature(SysMLv2Parser.UsageDeclarationContext? decl, string keyword) + { + var declaredName = GetDeclaredName(decl?.identification()); + var name = declaredName ?? $"${keyword}{_anonymousNodeCounter++}"; + + return new SysmlFeatureNode + { + Name = name, + QualifiedName = declaredName is not null ? QualifyName(name) : null, + FeatureKeyword = keyword, + Children = Array.Empty(), + Annotations = Array.Empty(), + }; + } + /// public override SysmlNode? VisitStateDefinition(SysMLv2Parser.StateDefinitionContext context) { @@ -1850,6 +2107,84 @@ private static IReadOnlyList GetSubclassificationSupertypes( return (children, annotations); } + /// + /// Collects an action body's child nodes by visiting its actionBodyItems in source + /// order, tracking as it goes so + /// can resolve the implicit incoming Source of a + /// leading bare then (sourceSuccessionMember) on any sibling. This is + /// deliberately kept separate from the generic (which + /// continues to serve state bodies and other item kinds unmodified) because only action + /// bodies currently need this order-sensitive "current flow position" bookkeeping — the + /// grammar's leading marker carries no name of its own, so its identity must come from + /// traversal state maintained here rather than from anything inside the visited node. + /// + private (IReadOnlyList Children, IReadOnlyList Annotations) + CollectActionBodyChildren(IEnumerable items) + { + var children = new List(); + var annotations = new List(); + var savedPreviousName = _actionBodyPreviousNodeName; + _actionBodyPreviousNodeName = null; + try + { + foreach (var item in items) + { + var node = Visit(item); + if (node is AnnotationCapture capture) + { + // A comment/doc annotation does not change the current flow position. + annotations.Add(capture.Annotation); + continue; + } + + if (node is MultiNodeCapture multi) + { + children.AddRange(multi.Nodes); + } + else if (node is not null) + { + children.Add(node); + } + + _actionBodyPreviousNodeName = DetermineFlowPositionName(item, node); + } + } + finally + { + _actionBodyPreviousNodeName = savedPreviousName; + } + + return (children, annotations); + } + + /// + /// Determines the name that represents the "current flow position" after visiting one + /// actionBodyItem, for use as the implicit Source of a subsequent sibling's + /// leading bare then. When the item produced a ending + /// in a synthesized (one or more trailing + /// actionTargetSuccessionMembers), the position moves to that succession's + /// Target (e.g. after first start then off;, the position is "off", + /// not "start"). Otherwise it is the visited node's own Name. A bare + /// first start; (no attached succession) still establishes "start" as the + /// position even though it synthesizes no AST node of its own. + /// + private static string? DetermineFlowPositionName(SysMLv2Parser.ActionBodyItemContext item, SysmlNode? node) + { + if (node is MultiNodeCapture { Nodes.Count: > 0 } multi) + { + return multi.Nodes[^1] is SysmlTransitionNode lastTransition + ? lastTransition.Target + : multi.Nodes[^1].Name; + } + + if (node is not null) + { + return node.Name; + } + + return item.initialNodeMember()?.qualifiedName()?.GetText(); + } + /// /// Collects child nodes from an array of . /// Comment/documentation annotations nested among the elements are separated out into diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs index dd57784b..fda2cba9 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/ActionFlowViewLayoutStrategyTests.cs @@ -666,4 +666,201 @@ public void ActionFlowView_BuildLayout_ExposedUsage_ResolvesThroughTypingToRoot( Assert.Contains(boxes, b => b.Label == "b1"); Assert.Contains(boxes, b => b.Label == "b2"); } + + /// + /// A named fork feeding two actions that later join renders a + /// badge at both the fork and the join, with the + /// succession edges wired between them (regression-guarding the ordinary-action rendering + /// path: b1/b2 still render as plain boxes). + /// + [Fact] + public void ActionFlowView_BuildLayout_ForkAndJoin_RenderHorizontalBarBadges() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var process = new SysmlDefinitionNode + { + Name = "Process", + QualifiedName = "P::Process", + DefinitionKeyword = "action def", + Children = + [ + new SysmlFeatureNode { Name = "a", QualifiedName = "P::Process::a", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "f", QualifiedName = null, FeatureKeyword = "fork" }, + new SysmlFeatureNode { Name = "b1", QualifiedName = "P::Process::b1", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "b2", QualifiedName = "P::Process::b2", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "j", QualifiedName = null, FeatureKeyword = "join" }, + new SysmlTransitionNode { Source = "a", Target = "f" }, + new SysmlTransitionNode { Source = "f", Target = "b1" }, + new SysmlTransitionNode { Source = "f", Target = "b2" }, + new SysmlTransitionNode { Source = "b1", Target = "j" }, + new SysmlTransitionNode { Source = "b2", Target = "j" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::Process"] = process } + }; + var context = new ViewContext("ActionFlow", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var bars = layout.Nodes.OfType().Where(b => b.Shape == BadgeShape.HorizontalBar).ToList(); + Assert.Equal(2, bars.Count); + Assert.Contains(bars, b => b.Label == "f"); + Assert.Contains(bars, b => b.Label == "j"); + + var actionBoxes = layout.Nodes.OfType().Where(b => b.Keyword == "action").ToList(); + Assert.Equal(3, actionBoxes.Count); + Assert.Contains(actionBoxes, b => b.Label == "a"); + Assert.Contains(actionBoxes, b => b.Label == "b1"); + Assert.Contains(actionBoxes, b => b.Label == "b2"); + } + + /// + /// A guarded decision feeding a merge renders a badge for + /// both the decision and the merge node. + /// + [Fact] + public void ActionFlowView_BuildLayout_DecisionAndMerge_RenderDiamondBadges() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var process = new SysmlDefinitionNode + { + Name = "Process", + QualifiedName = "P::Process", + DefinitionKeyword = "action def", + Children = + [ + new SysmlFeatureNode { Name = "monitor", QualifiedName = "P::Process::monitor", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "d", QualifiedName = null, FeatureKeyword = "decide" }, + new SysmlFeatureNode { Name = "addCharge", QualifiedName = "P::Process::addCharge", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "m", QualifiedName = null, FeatureKeyword = "merge" }, + new SysmlTransitionNode { Source = "monitor", Target = "d" }, + new SysmlTransitionNode { Source = "d", Target = "addCharge", Guard = "monitor.charge<100" }, + new SysmlTransitionNode { Source = "d", Target = "m" }, + new SysmlTransitionNode { Source = "addCharge", Target = "m" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::Process"] = process } + }; + var context = new ViewContext("ActionFlow", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var diamonds = layout.Nodes.OfType().Where(b => b.Shape == BadgeShape.Diamond).ToList(); + Assert.Equal(2, diamonds.Count); + Assert.Contains(diamonds, b => b.Label == "d"); + Assert.Contains(diamonds, b => b.Label == "m"); + } + + /// + /// Accept/send nodes render as rounded-rectangle boxes (like ordinary actions) but keep + /// their own distinct keyword instead of the hard-coded "action" keyword. + /// + [Fact] + public void ActionFlowView_BuildLayout_AcceptAndSend_RenderBoxesWithDistinctKeyword() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var process = new SysmlDefinitionNode + { + Name = "Process", + QualifiedName = "P::Process", + DefinitionKeyword = "action def", + Children = + [ + new SysmlFeatureNode { Name = "sig", QualifiedName = "P::Process::sig", FeatureKeyword = "accept" }, + new SysmlFeatureNode { Name = "msg", QualifiedName = "P::Process::msg", FeatureKeyword = "send" }, + new SysmlTransitionNode { Source = "sig", Target = "msg" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::Process"] = process } + }; + var context = new ViewContext("ActionFlow", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var boxes = layout.Nodes.OfType().ToList(); + Assert.Contains(boxes, b => b.Keyword == "accept" && b.Label == "sig"); + Assert.Contains(boxes, b => b.Keyword == "send" && b.Label == "msg"); + Assert.DoesNotContain(layout.Nodes.OfType(), b => b.Shape is BadgeShape.Diamond or BadgeShape.HorizontalBar); + } + + /// + /// A synthetic $-prefixed anonymous control-node name (as synthesized by + /// AstBuilder for an unnamed fork/decide/etc.) is never shown as the badge's label. + /// + [Fact] + public void ActionFlowView_BuildLayout_SyntheticControlNodeName_RendersBlankLabel() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var process = new SysmlDefinitionNode + { + Name = "Process", + QualifiedName = "P::Process", + DefinitionKeyword = "action def", + Children = + [ + new SysmlFeatureNode { Name = "a", QualifiedName = "P::Process::a", FeatureKeyword = "action" }, + new SysmlFeatureNode { Name = "$fork0", QualifiedName = null, FeatureKeyword = "fork" }, + new SysmlFeatureNode { Name = "b1", QualifiedName = "P::Process::b1", FeatureKeyword = "action" }, + new SysmlTransitionNode { Source = "a", Target = "$fork0" }, + new SysmlTransitionNode { Source = "$fork0", Target = "b1" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::Process"] = process } + }; + var context = new ViewContext("ActionFlow", workspace); + var options = new RenderOptions(Themes.Light); + + var layout = strategy.BuildLayout(context, options); + + var bar = Assert.Single(layout.Nodes.OfType(), b => b.Shape == BadgeShape.HorizontalBar); + Assert.Null(bar.Label); + } + + /// + /// The compact action a; then b; idiom (an action followed directly by an attached + /// succession, as produced by AstBuilder.VisitActionBodyItem) resolves both nodes and + /// wires the succession between them. + /// + [Fact] + public void ActionFlowView_BuildLayout_CompactActionThenIdiom_ResolvesBothNodes() + { + var strategy = new ActionFlowViewLayoutStrategy(); + var process = new SysmlDefinitionNode + { + Name = "Process", + QualifiedName = "P::Process", + DefinitionKeyword = "action def", + Children = + [ + new SysmlFeatureNode { Name = "a", QualifiedName = "P::Process::a", FeatureKeyword = "action" }, + new SysmlTransitionNode { Source = "a", Target = "b" }, + new SysmlFeatureNode { Name = "b", QualifiedName = "P::Process::b", FeatureKeyword = "action" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::Process"] = process } + }; + var context = new ViewContext("ActionFlow", 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.Equal(2, boxes.Count); + Assert.Contains(boxes, b => b.Label == "a"); + Assert.Contains(boxes, b => b.Label == "b"); + Assert.Contains(layout.Nodes.OfType(), l => l.LineStyle == LineStyle.Dashed); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs index 899bb325..02de1d7a 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs @@ -235,4 +235,111 @@ public async Task Transition_OmgCorpusFixture_ResolvesAllStatesAndTransitions() Assert.Contains(actionFeatures, f => f.FeatureKeyword == "do" && f.Name == "providePower"); Assert.Contains(actionFeatures, f => f.FeatureKeyword == "exit" && f.Name == "applyParkingBrake"); } + + /// + /// The dedicated 17.Control corpus fixtures exercise the two Action Flow View + /// sub-problems together: the combined-succession actionBodyItem shapes (the compact + /// action a1; then a2; idiom, plus first start;/first start then X;) + /// and the six control-node kinds (merge/decide/join/fork/ + /// accept/send), including the dominant real-world anonymous-node idiom + /// (then fork;/then decide; with no declared name, immediately followed by + /// several then <name>;/guarded target successions). Assertions are on raw + /// / text + /// and feature kind/name — not full reference resolution — per the design decision that an + /// anonymous control node's synthesized $-prefixed name is expected to produce an + /// "Unresolved reference" warning (cosmetic only: ActionFlowViewLayoutStrategy reads + /// the raw text directly, never ResolvedEdges). Each fixture's leading `then` + /// (sourceSuccessionMember) is also asserted to synthesize its implicit incoming + /// succession from the immediately preceding sibling (e.g. TurnOn->$fork0, + /// J->F, and the ChargeBattery declare-then-declare chain), since the + /// grammar's leading marker itself carries no name of its own. + /// + [Fact] + public async Task ControlNode_OmgCorpusFixture_ResolvesForkJoinDecisionMerge() + { + var omgRoot = FindOmgModelsRoot(); + var forkJoinFile = Path.Combine(omgRoot, "training", "17.Control", "ForkJoinExample.sysml"); + var decisionFile = Path.Combine(omgRoot, "training", "17.Control", "DecisionExample.sysml"); + var controlNodeFile = Path.Combine(omgRoot, "examples", "SimpleTests", "ControlNodeTest.sysml"); + Assert.All( + [forkJoinFile, decisionFile, controlNodeFile], + f => Assert.True(File.Exists(f), $"Expected fixture not found: {f}")); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([forkJoinFile, decisionFile, controlNodeFile], stdlibTable); + + // ForkJoinExample.sysml: an anonymous fork feeding 3 branches that all join back together. + var brake = (SysmlDefinitionNode)result.Workspace!.Declarations["'Fork Join Example'::Brake"]; + var brakeFork = Assert.Single( + brake.Children.OfType(), f => f.FeatureKeyword == "fork"); + Assert.NotNull(brakeFork.Name); + Assert.StartsWith("$", brakeFork.Name, StringComparison.Ordinal); + + var forkSuccessions = brake.Children + .OfType() + .Where(t => t.Source == brakeFork.Name) + .ToList(); + Assert.Equal(3, forkSuccessions.Count); + Assert.Contains(forkSuccessions, t => t.Target == "monitorBrakePedal"); + Assert.Contains(forkSuccessions, t => t.Target == "monitorTraction"); + Assert.Contains(forkSuccessions, t => t.Target == "braking"); + + // The fork's own leading `then` (sourceSuccessionMember) synthesizes the implicit + // incoming succession from the immediately preceding sibling action `TurnOn`. + Assert.Contains( + brake.Children.OfType(), + t => t.Source == "TurnOn" && t.Target == brakeFork.Name); + + var joinNode = Assert.Single( + brake.Children.OfType(), f => f.FeatureKeyword == "join"); + Assert.Equal("joinNode", joinNode.Name); + Assert.Equal(3, brake.Children.OfType().Count(t => t.Target == "joinNode")); + + // DecisionExample.sysml: an anonymous decide with two guarded successions, plus a named + // merge that the decide's default-path action succession eventually rejoins. + var chargeBattery = (SysmlDefinitionNode)result.Workspace!.Declarations["'Decision Example'::ChargeBattery"]; + var decide = Assert.Single( + chargeBattery.Children.OfType(), f => f.FeatureKeyword == "decide"); + Assert.NotNull(decide.Name); + Assert.StartsWith("$", decide.Name, StringComparison.Ordinal); + + var decideSuccessions = chargeBattery.Children + .OfType() + .Where(t => t.Source == decide.Name) + .ToList(); + Assert.Equal(2, decideSuccessions.Count); + Assert.Contains(decideSuccessions, t => t.Target == "addCharge" && t.Guard == "monitor.batteryCharge<100"); + Assert.Contains(decideSuccessions, t => t.Target == "endCharging" && t.Guard == "monitor.batteryCharge>=100"); + + Assert.Contains( + chargeBattery.Children.OfType(), + f => f.FeatureKeyword == "merge" && f.Name == "continueCharging"); + + // Each of ChargeBattery's leading-`then` chain items (`first start; then merge + // continueCharging; then action monitor: MonitorBattery{...}; then decide;`) synthesizes + // its own implicit incoming succession from the immediately preceding sibling. + var chargeBatteryTransitions = chargeBattery.Children.OfType().ToList(); + Assert.Contains(chargeBatteryTransitions, t => t.Source == "start" && t.Target == "continueCharging"); + Assert.Contains(chargeBatteryTransitions, t => t.Source == "continueCharging" && t.Target == "monitor"); + Assert.Contains(chargeBatteryTransitions, t => t.Source == "monitor" && t.Target == decide.Name); + + // ControlNodeTest.sysml: fully named fork/join/merge — the richest, most reliable fixture. + var controlNodeTest = (SysmlDefinitionNode)result.Workspace!.Declarations["ControlNodeTest"]; + var controlFeatures = controlNodeTest.Children.OfType().ToList(); + Assert.Contains(controlFeatures, f => f.FeatureKeyword == "join" && f.Name == "J"); + Assert.Contains(controlFeatures, f => f.FeatureKeyword == "fork" && f.Name == "F"); + Assert.Contains(controlFeatures, f => f.FeatureKeyword == "merge" && f.Name == "M"); + + var controlTransitions = controlNodeTest.Children.OfType().ToList(); + Assert.Contains(controlTransitions, t => t.Source == "A1" && t.Target == "J"); + Assert.Contains(controlTransitions, t => t.Source == "A2" && t.Target == "J"); + Assert.Contains(controlTransitions, t => t.Source == "F" && t.Target == "B1"); + Assert.Contains(controlTransitions, t => t.Source == "F" && t.Target == "B2"); + Assert.Contains(controlTransitions, t => t.Source == "B1" && t.Target == "M"); + Assert.Contains(controlTransitions, t => t.Source == "B2" && t.Target == "M"); + + // The join's own leading `then` synthesizes the implicit incoming succession from the + // immediately preceding sibling `J`. + Assert.Contains(controlTransitions, t => t.Source == "J" && t.Target == "F"); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index f880f956..02fef40e 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -3908,6 +3908,336 @@ view def V {} } } + /// + /// The compact action a1; then a2; idiom (the + /// (sourceSuccessionMember)? actionBehaviorMember (actionTargetSuccessionMember)* + /// actionBodyItem alternative) resolves both the action and its attached succession. + /// Before this fix, ANTLR's default VisitChildren aggregation silently dropped the + /// action, keeping only the succession (or vice versa). + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_CompactActionThenIdiom_ResolvesBothNodes() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package AF { + action def Flow { + action a1; + then a2; + action a2; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var flow = Assert.IsType( + result.Workspace!.Declarations["AF::Flow"]); + var actions = flow.Children + .OfType() + .Count(f => f.FeatureKeyword == "action"); + Assert.Equal(2, actions); + + var succession = flow.Children + .OfType() + .Single(); + Assert.Equal("a1", succession.Source); + Assert.Equal("a2", succession.Target); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Multiple actionTargetSuccessionMembers attached after a single + /// actionBehaviorMember (e.g. a fork's three outgoing branches) are all captured, + /// each sharing the preceding node's name as their implicit Source. This also + /// verifies the fork's own leading then (sourceSuccessionMember) synthesizes + /// the implicit *incoming* succession from the immediately preceding sibling (a), + /// since the grammar's leading marker itself carries no name of its own. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_MultipleActionTargetSuccessions_CapturesAll() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package AF { + action def Flow { + action a; + then fork f; + then b1; + then b2; + action b1; + action b2; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var flow = Assert.IsType( + result.Workspace!.Declarations["AF::Flow"]); + var successions = flow.Children + .OfType() + .Where(t => t.Source == "f") + .ToList(); + Assert.Equal(2, successions.Count); + Assert.Contains(successions, t => t.Target == "b1"); + Assert.Contains(successions, t => t.Target == "b2"); + + var incoming = flow.Children + .OfType() + .Single(t => t.Target == "f"); + Assert.Equal("a", incoming.Source); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A bare first start; (no attached target succession) produces no succession node + /// — unchanged from today, since ActionFlowViewLayoutStrategy infers its start/done + /// markers purely from succession topology, not a declarative initial marker. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_BareInitialNodeMember_ProducesNoSuccession() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package AF { + action def Flow { + action a; + first a; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var flow = Assert.IsType( + result.Workspace!.Declarations["AF::Flow"]); + Assert.Empty(flow.Children.OfType()); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// first start then off; (an initialNodeMember with an attached target + /// succession) synthesizes a + /// whose Source is the referenced qualified name and whose Target is the + /// attached succession's target. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_InitialNodeMemberWithAttachedSuccession_SynthesizesTransition() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package AF { + action def Flow { + first start then off; + action off; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var flow = Assert.IsType( + result.Workspace!.Declarations["AF::Flow"]); + var succession = flow.Children + .OfType() + .Single(); + Assert.Equal("start", succession.Source); + Assert.Equal("off", succession.Target); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Anonymous fork/decide/join/merge/accept/send + /// control nodes each register a + /// with the correct FeatureKeyword and a non-null synthetic $-prefixed + /// Name (rather than the name left by a genuinely anonymous + /// plain action), since fork/decide/send are the dominant real-world idiom for anonymous + /// control nodes per the OMG training corpus. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_AnonymousControlNodes_SynthesizeNames() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package AF { + action def Flow { + action a; + then fork; + then b1; + then b2; + action b1; + action b2; + then join; + then decide; + if true then b1; + else b2; + then merge; + accept sig; + then send; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var flow = Assert.IsType( + result.Workspace!.Declarations["AF::Flow"]); + var features = flow.Children.OfType().ToList(); + + foreach (var keyword in new[] { "fork", "join", "decide", "merge" }) + { + var feature = features.Single(f => f.FeatureKeyword == keyword); + Assert.NotNull(feature.Name); + Assert.StartsWith("$", feature.Name, StringComparison.Ordinal); + Assert.Null(feature.QualifiedName); + } + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Named control nodes (e.g. fork f;, join j;) keep their declared name + /// instead of a synthesized one, and — unlike anonymous control nodes — are given a + /// non-null, fully-qualified QualifiedName (mirroring BuildStateActionFeatureNode's + /// handling of named entry/do/exit actions), so they are correctly registered in the + /// symbol table and subject to expose-scope filtering. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_NamedControlNodes_KeepDeclaredName() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package AF { + action def Flow { + action a; + then fork f; + then b1; + then b2; + action b1; + action b2; + then join j; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var flow = Assert.IsType( + result.Workspace!.Declarations["AF::Flow"]); + var features = flow.Children.OfType().ToList(); + + var fork = Assert.Single(features, f => f.FeatureKeyword == "fork" && f.Name == "f"); + Assert.Equal("AF::Flow::f", fork.QualifiedName); + + var join = Assert.Single(features, f => f.FeatureKeyword == "join" && f.Name == "j"); + Assert.Equal("AF::Flow::j", join.QualifiedName); + + // The named control nodes must actually be registered in the symbol table (this is + // the crux of the fix: previously QualifiedName was always null, so RegisterAll never + // registered named fork/join/merge/decide/accept/send nodes). + Assert.True(result.Workspace!.Declarations.TryGetValue("AF::Flow::f", out var forkSymbol)); + Assert.Same(fork, forkSymbol); + Assert.True(result.Workspace!.Declarations.TryGetValue("AF::Flow::j", out var joinSymbol)); + Assert.Same(join, joinSymbol); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Both the guarded (if ... then ...) and default (else ...) + /// actionTargetSuccession variants extract a target; only the guarded form captures + /// a guard expression (the grammar provides none for the else alternative). + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_GuardedAndDefaultActionTargetSuccession_ExtractTargets() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, """ + package AF { + action def Flow { + action monitor; + then decide d; + if true then addCharge; + else endCharging; + action addCharge; + action endCharging; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var flow = Assert.IsType( + result.Workspace!.Declarations["AF::Flow"]); + var successions = flow.Children + .OfType() + .Where(t => t.Source == "d") + .ToList(); + Assert.Equal(2, successions.Count); + + var guarded = successions.Single(t => t.Target == "addCharge"); + Assert.Equal("true", guarded.Guard); + + var defaulted = successions.Single(t => t.Target == "endCharging"); + Assert.Null(defaulted.Guard); + } + finally + { + File.Delete(tempFile); + } + } + /// /// Finds the test/SysMLModels directory relative to the test assembly. ///