diff --git a/ROADMAP.md b/ROADMAP.md index 18f300b..612bfdc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -69,16 +69,45 @@ gallery currently shows. **Depends on:** "Additional relationship edges (General View)" above (needs the new edge kinds implemented first). -### Annotating elements & compartment depth - -- Render **Documentation/Comment** notes as `BoxShape.Note` (folded-corner) nodes attached to - their annotated element. -- Extend compartments to spec depth: enumeration values, constraint bodies, requirement - `subject`/`constraints`/`doc`, and a documentation compartment on definitions/usages. - -**Scope:** semantic exposure of doc/comment + compartment content; `GeneralViewLayoutStrategy` -and renderers; possibly `LayoutLabel`/compartment tweaks. -**Visual gate:** a documented requirement/part renders its note and full compartments. +### Annotating elements & compartment depth — delivered + +**Delivered.** `AstBuilder` now captures every content kind this item scoped: `VisitEnumeratedValue` +builds an `"enum value"`-keyword feature for each `enum def` literal (bare, value-assignment, and +redefinition-body forms), via a dedicated `CollectEnumerationBodyChildren` helper (needed because +`enumerationBody` uniquely alternates `annotatingMember`/`enumerationUsageMember` directly rather +than wrapping them in a single rule). `VisitRequirementDefinition`/`VisitConcernDefinition` and — +extending beyond a literal reading of this item, since the dominant real-corpus idiom nests these +inside a requirement/concern *usage* rather than a definition — `VisitRequirementUsage`/ +`VisitConcernUsage` now capture `subject`/`actor`/`stakeholder` members (`VisitSubjectUsage`/ +`VisitActorUsage`/`VisitStakeholderUsage`) and `require constraint`/`assume constraint` members +(`VisitRequirementConstraintMember`/`BuildConstraintFeatureNode`, capturing the raw expression text +into a new `SysmlFeatureNode.ExpressionText` field mirroring `SysmlTransitionNode.Guard`). +`VisitConstraintUsage` (previously entirely absent) and a rewritten `VisitConstraintDefinition` +capture standalone/definition-level constraint expressions the same way. A newly-introduced +`VisitRequirementVerificationMember`/`VisitFramedConcernMember` null-suppression safeguard +prevents a `verify`/`frame` member's own nested body from being spuriously hoisted onto the +enclosing requirement's `Children` now that body-collection reaches into `requirementBodyItem`. + +`GeneralViewLayoutStrategy` now reads `Annotations`: `AddAnnotationNote` emits one `BoxShape.Note` +box per annotated definition (concatenating multiple `Documentation`/`Comment` annotations into a +single note, documented as a deliberate choice) connected by a plain solid line with no end +marker — implemented as ordinary `LayoutGraph` nodes/edges added during the existing single-pass +`BuildGraph` traversal, a documented deviation from a literal post-layout marker-decoration +approach (no equivalent decoration phase exists in this strategy). `Pluralize` titles +`"subject"`/`"assume constraint"`/`"require constraint"`/`"constraint"` compartments with the +guillemet stereotype form (reusing the pre-existing `«allocate»` edge-label convention), and +`FormatFeatureRow` renders a constraint-kind feature's raw expression text in place of the generic +`name : Type [multiplicity]` row shape. + +The Quadcopter Drone gallery model (`docs/gallery/models/01-drone-general.sysml`) was extended +with a `doc`-annotated `Battery`, an `enum def FlightMode` with literal values, and a +`FlightTimeRequirement` with a `subject`/`require constraint` body, and regenerated. + +**Explicitly deferred (not delivered here):** full expression-tree modeling of constraint bodies +(raw text capture only); a nested `doc` annotation inside a constraint's own `calculationBody` +(e.g. `assume constraint { doc /* ... */ ... }`, seen in the OMG training corpus) is intentionally +not captured — an accepted scope boundary, not an oversight; any other view kind besides General +View. ### Action Flow View: control-node/successor AST correctness + fork/join/decision/merge shapes — delivered 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 e51e680..a7a939c 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 @@ -21,7 +21,9 @@ box title and folder-tab geometry come from `BoxMetrics` in `DemaConsulting.Rend `GeneralViewLayoutStrategy` has no instance state; all input arrives through the `BuildLayout` parameters. Layout constants (`MinBoxWidth`, `CharWidthFactor`) are declared as `private const` fields. Private records carry intermediate data: `DefBox` (a user definition with its computed -size, keyword, supertype names, memberships, and compartments), `ModelEdge` (a resolved +size, keyword, supertype names, memberships, compartments, and `Annotations` — the definition's +own `SysmlNode.Annotations` list, threaded through by `CollectDefinitions` so `AddAnnotationNote` +(below) can render them without a second workspace lookup), `ModelEdge` (a resolved specialization/membership/attribute-typing/redefinition/subsetting/connect/allocate/dependency/ binding relationship expressed by qualified name, together with its target end marker, edge kind, and an optional midpoint label), `Location` (a located definition's @@ -33,10 +35,13 @@ carries the short kind label, raw source/target reference, and human-readable re rendered boxes, feeding `LayoutWarnings.ForDroppedRelationshipEdges`. `FeatureMembership` (a private record) carries each owned feature's keyword, raw type reference (`TypeName`, nullable — a feature may declare a redefinition with no explicit type annotation), simple `Name`, raw -`RedefinedFeatureName` reference, and the raw `SubsettedFeatureNames` list (populated verbatim -from `SysmlFeatureNode.SupertypeNames` — a feature's `subsets`/`:>` targets, not a new AST field); -`CollectMemberships` includes a feature when `TypeName`, `RedefinedFeatureName`, or a non-empty -`SubsettedFeatureNames` is present. The private `EdgeKind` enumeration classifies each edge as +`RedefinedFeatureName` reference, the raw `SubsettedFeatureNames` list (populated verbatim +from `SysmlFeatureNode.SupertypeNames` — a feature's `subsets`/`:>` targets, not a new AST field), +and `ExpressionText` (verbatim from `SysmlFeatureNode.ExpressionText`, non-null only for +constraint-kind features such as `"require constraint"`/`"assume constraint"`/`"constraint"`); +`CollectMemberships` includes a feature when `TypeName`, `RedefinedFeatureName`, +`ExpressionText`, or a non-empty `SubsettedFeatureNames` is present. The private `EdgeKind` +enumeration classifies each edge as `Specialization`, `Membership`, `Typing`, `Redefinition`, `Subsetting`, `Connect`, `Allocate`, `Dependency`, or `Binding`; `Subsetting` is a purely view-layer classification — it does not correspond to a public `SysmlEdgeKind` — reusing `SupertypeNames` and the same owner-resolution @@ -87,10 +92,26 @@ the `LayoutTree with { Warnings = … }` record-copy idiom. 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 `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 +compartments from the owned usage features (grouped by keyword, each formatted via +`FormatFeatureRow`), collects the typed memberships, and computes the box size from the title and the longest compartment row. +Each compartment's title comes from `Pluralize(keyword)`, which special-cases a small set of +individually-significant single-member keywords to a guillemet-wrapped stereotype form instead of +the generic pluralized-keyword default: `"subject"` → `"«subject»"`, `"assume constraint"` → +`"«assume constraint»"`, `"require constraint"` → `"«require constraint»"`, and `"constraint"` → +`"«constraint»"`. This reuses the exact guillemet convention already established elsewhere in this +file for the `«allocate»` edge midpoint label (see `BuildModelEdges`), rather than inventing a new +one. Every other keyword introduced alongside this work — `"enum value"` (rendered as the +compartment title `"enum values"`), `"actor"`, `"stakeholder"` — falls through to the generic +pluralized-keyword default, matching the OMG spec figures' own compartment-heading conventions for +those kinds. + +`FormatFeatureRow` renders a feature with a non-null, non-empty `ExpressionText` (constraint-kind +features only) as its raw expression text alone (unnamed) or as `"{name}: {expr}"` (named) +instead of the generic `"name : Type [multiplicity]"` shape used by every other feature kind, +since a constraint's defining content is its expression, not a type/multiplicity pair. + ###### `GroupByPackage(defs)` Groups definitions by the qualified-name prefix before the last `::`, preserving first-seen order. @@ -244,6 +265,33 @@ map to reference and is silently dropped, exactly as before. Adds one definition as a leaf node to the given scope (the root graph or a folder's `Children`), carrying its `Label`, `Shape = Rectangle`, `Keyword`, and `Compartments`. +###### `AddAnnotationNote(scope, def, defNode, theme)` + +Called immediately after `MakeDefNode` for every definition (both top-level and folder-scoped), +this no-ops when `def.Annotations` is empty; otherwise it adds one `BoxShape.Note` node (id +`note:{qualifiedName}`) to the same `scope` as `defNode`, plus a plain solid edge with no end +marker (id `note-edge:{qualifiedName}`, `TargetEnd = EndMarkerStyle.None`) from the note to the +definition. `BuildNoteText` concatenates every annotation's trimmed text with a blank-line +separator, so **one note box renders per annotated element**, never one box per individual +`comment`/`doc` — a definition with both a `doc` and two `comment`s still gets a single note. +`ComputeNoteBoxSize` sizes the note from the longest line and total line count using +`BoxMetrics.TitleAreaHeight(theme, hasLabel: false, hasKeyword: false)` for the (title-less) top +band plus per-line height and padding — deliberately simpler than the full `ComputeBoxSize` used +for definition boxes, since a note has no title/keyword band. The note's single +`LayoutCompartment` carries `Title = null` and one row per line of the concatenated text; no +`Label`/`Keyword` is set on the note node itself, matching conventional UML/SysML note rendering +(body text only). + +This is implemented as ordinary `LayoutGraph` nodes and edges added during the same +single-pass `BuildGraph`/`CollectDefinitions` traversal that builds each definition's own box — a +deliberate, documented deviation from a literal "post-layout marker decoration" approach (the +pattern used by, e.g., `StateTransitionViewLayoutStrategy.AddInitialMarker`, which stamps a marker +onto an already-placed box after layout completes). This strategy has no equivalent +post-layout decoration phase — its entire graph, including folder containers and truncated-folder +placeholders, is built in one `BuildGraph` pass before layout ever runs — so adding the note as an +ordinary graph node/edge pair up front is the natural fit, and lets the layout engine route and +position it like any other element rather than requiring bespoke placement math. + ###### `DecorateTruncatedFolders(tree, graph, truncated, theme)` Replaces each truncated folder's placed box with one carrying its "+N more…" ellipsis label, 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 b5faeda..c94341c 100644 --- a/docs/design/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/design/sysml2-tools-language/semantic/model/ast-builder.md @@ -40,6 +40,15 @@ stack with `::` to form the fully-qualified name. | `VisitForkNode` | `ForkNodeContext` | `SysmlFeatureNode` (`FeatureKeyword = "fork"`) | | `VisitAcceptNode` | `AcceptNodeContext` | `SysmlFeatureNode` (`FeatureKeyword = "accept"`) | | `VisitSendNode` | `SendNodeContext` | `SysmlFeatureNode` (`FeatureKeyword = "send"`) | +| `VisitEnumeratedValue` | `EnumeratedValueContext` | `SysmlFeatureNode` (`FeatureKeyword = "enum value"`) | +| `VisitSubjectUsage` | `SubjectUsageContext` | `SysmlFeatureNode` (`FeatureKeyword = "subject"`) | +| `VisitActorUsage` | `ActorUsageContext` | `SysmlFeatureNode` (`FeatureKeyword = "actor"`) | +| `VisitStakeholderUsage` | `StakeholderUsageContext` | `SysmlFeatureNode` (`FeatureKeyword = "stakeholder"`) | +| `VisitRequirementConstraintMember` | `RequirementConstraintMemberContext` | require/assume constraint feature | +| `VisitConstraintUsage` | `ConstraintUsageContext` | `SysmlFeatureNode` (`"constraint"`, `ExpressionText` set) | +| `VisitConstraintDefinition` | `ConstraintDefinitionContext` | def + synthesized `"constraint"` child feature | +| `VisitRequirementVerificationMember` | `RequirementVerificationMemberContext` | `null` (suppresses hoisting) | +| `VisitFramedConcernMember` | `FramedConcernMemberContext` | `null` (suppresses hoisting) | `GetDeclaredName(IdentificationContext)` handles the three grammar alternatives: @@ -228,11 +237,20 @@ direct `qualifiedName()` child (which is null for this alternative). `VisitImpor `ExtractExposedNames` both call this one helper rather than duplicating the extraction logic, per the Copy-Paste Programming anti-pattern guidance in coding-principles.md. -`VisitRequirementUsage` performs a minimal capture (name/qualified-name only, so named -requirement usages become resolvable symbols) and additionally invokes `FindVerificationMembers` -against its own `requirementBody()` (when present) to populate `VerifiedRequirementNames` — -covering the case where a `verify` member appears directly inside a `requirement { }` usage -body. +`VisitRequirementUsage` and `VisitConcernUsage` push a namespace scope (when named) and call +`CollectChildren(body?.requirementBodyItem() ?? [])` in addition to their existing name/qualified- +name capture, so a requirement/concern *usage*'s own `subject`/`actor`/`stakeholder`/constraint +members are captured as `Children` — not only a requirement/concern *definition*'s. This is a +deliberate extension beyond a literal reading of "definitions only": the dominant idiom in the +real OMG training corpus (e.g. `test/SysMLModels/OMG/training/32.Requirements/ +RequirementUsages.sysml`) nests `subject`/`assume constraint` inside a requirement usage that +specializes a requirement def (`requirement <'1.1'> fullVehicleMassLimit : +VehicleMassLimitationRequirement { subject vehicle : Vehicle; assume constraint { ... } }`), not +inside the def's own body — without extending usages, this compartment-depth work would not +manifest for the corpus's actual idiomatic shape. `VisitRequirementUsage` additionally invokes +`FindVerificationMembers` against its own `requirementBody()` (when present) to populate +`VerifiedRequirementNames` — covering the case where a `verify` member appears directly inside a +`requirement { }` usage body. `FindVerificationMembers(IParseTree root)` / `CollectVerificationMembers(IParseTree, List)` / `ExtractVerifiedRequirementName(RequirementVerificationUsageContext?)` are a narrow, additive @@ -249,9 +267,75 @@ placeholder form's feature typing (`verify requirement : ;`, via th `VisitAnalysisCaseDefinition`, `VisitVerificationCaseDefinition` (via an optional `specializedBody` parameter added to the shared `BuildDefinitionFromDeclaration`, defaulting to `null` for backward compatibility with callers that don't have a specialized body to scan), and -`VisitRequirementUsage` (via its own `requirementBody()`). This walk is safe from double-counting -because nothing else in `AstBuilder` currently visits into `requirementBody`/`caseBody` -subtrees. +`VisitRequirementUsage`/`VisitConcernUsage` (via their own `requirementBody()`). + +**Enumeration literal values.** `VisitEnumerationDefinition` pushes a namespace scope and calls a +dedicated `CollectEnumerationBodyChildren(EnumerationBodyContext?)` helper, rather than +`CollectChildren` directly: `enumerationBody` uniquely alternates `annotatingMember | +enumerationUsageMember` directly (`LBRACE ( annotatingMember | enumerationUsageMember )* +RBRACE`), unlike every other body rule in this unit (`definitionBody`/`requirementBody`/ +`stateBody`/`actionBody`), which wrap their alternatives in a single intermediate rule that +`CollectChildren` can iterate directly. `CollectEnumerationBodyChildren` walks the body's raw +`context.children`, filters to `Antlr4.Runtime.ParserRuleContext` instances (dropping the +`LBRACE`/`RBRACE` terminal nodes), and delegates the filtered list to the existing +`CollectChildren` — a narrow, documented exception rather than a generalization of +`CollectChildren` itself. `VisitEnumeratedValue` builds a usage node via the existing +`BuildUsageNode` helper with `FeatureKeyword = "enum value"` (distinct from the bare `"enum"` +keyword used by `VisitEnumerationUsage`, to avoid a compartment-title collision), covering all +three literal forms observed in the OMG corpus: bare (`enum green;`), value-assignment +(`A = 4.0;`), and redefinition-body (`unclassified { :>> code = "..."; }`) — the assigned value +expression itself is not parsed in any form, an accepted minimal-capture gap. + +**Requirement subject, actor, stakeholder, and constraint members.** `VisitRequirementDefinition` +and `VisitConcernDefinition` push a namespace scope and call +`CollectChildren(body?.requirementBodyItem() ?? [])`, mirroring `VisitStateUsage`'s existing +push-scope/body-collect/register pattern; `VisitSubjectUsage`, `VisitActorUsage`, and +`VisitStakeholderUsage` each build a minimal usage node (via `BuildUsageNode`) with +`FeatureKeyword` `"subject"`, `"actor"`, `"stakeholder"` respectively. +`VisitRequirementConstraintMember` reads `context.requirementKind()?.REQUIRE()` to choose between +`"require constraint"` and `"assume constraint"` (defaulting to `"assume constraint"` when +neither `ASSUME` nor `REQUIRE` is present — the grammar treats `requirementKind` as optional), and +delegates to a new `BuildConstraintFeatureNode(RequirementConstraintUsageContext?, string +keyword)` helper. That helper handles both `requirementConstraintUsage` grammar alternatives: the +reference form (`ownedReferenceSubsetting featureSpecializationPart? requirementBody`, whose raw +referenced-name text is captured into `ExpressionText`) and the inline form +(`(usageExtensionKeyword* CONSTRAINT | usageExtensionKeyword+) constraintUsageDeclaration +calculationBody`, whose name comes from `constraintUsageDeclaration()?.usageDeclaration()? +.identification()` and whose raw `calculationBody().GetText()` is captured into +`ExpressionText`). A nested `doc` inside a constraint's own `calculationBody` (as seen in the +corpus's `assume constraint { doc /* ... */ ... }` idiom) is intentionally **not** captured — +constraint bodies are captured only as raw expression text, with no nested-member traversal; +this is a deliberate, accepted scope boundary rather than an oversight. + +**Standalone constraint usage and definition.** `VisitConstraintUsage` (previously entirely +absent) captures a top-level `constraint { expr }` usage's name and raw +`calculationBody()?.GetText()` into `ExpressionText`, with `FeatureKeyword = "constraint"`. +`VisitConstraintDefinition` no longer delegates to the generic `BuildDefinitionFromDeclaration` +(which has no way to expose a `constraintDefinition`'s own body, since that body is a +`calculationBody`, not a `definitionBody`); it instead builds the definition's name/qualified- +name/supertypes directly and synthesizes a single child `SysmlFeatureNode { FeatureKeyword = +"constraint", ExpressionText = context.calculationBody()?.GetText() }`, reusing the same +`"constraint"`-keyword/`ExpressionText` shape as a nested requirement constraint so +`GeneralViewLayoutStrategy` can render both uniformly. + +**Verify/frame null-suppression safeguard.** Once `VisitRequirementDefinition`, +`VisitConcernDefinition`, `VisitRequirementUsage`, and `VisitConcernUsage` began calling +`CollectChildren` over `requirementBodyItem`, ANTLR's default last-non-null-wins +`AggregateResult` dispatch would, for the first time, recurse into a nested `verify`/`frame` +member's own `requirementBody` — since `requirementVerificationMember` and `framedConcernMember` +are themselves `requirementBodyItem` alternatives with their own nested bodies that can contain +real content (e.g. a nested `subjectMember`) — and incorrectly bubble that nested content up as +if it were a direct child of the *outer* enclosing requirement/concern, rather than staying +scoped to the inner `verify`/`frame` member. `VisitRequirementVerificationMember` and +`VisitFramedConcernMember` now explicitly return `null`, short-circuiting that recursive descent; +the verify member's own target is still captured separately and correctly via the pre-existing +`FindVerificationMembers`/`VerifiedRequirementNames` mechanism above, which is unaffected by this +change since it performs its own independent tree walk rather than relying on `CollectChildren`. + +Note: unlike the transition-related walk in `FindVerificationMembers` above, this per-body +`CollectChildren(requirementBodyItem())` traversal is *not* immune to double-counting by +construction — it is precisely because it now visits into `requirementBody`/`caseBody` subtrees +that the null-suppression safeguard above is required. `VisitStateUsage` additionally calls `ExtractFeatureTyping(decl?.featureSpecializationPart())` and sets the result on the constructed `SysmlFeatureNode`'s `FeatureTyping` property — previously this diff --git a/docs/design/sysml2-tools-language/semantic/model/sysml-node.md b/docs/design/sysml2-tools-language/semantic/model/sysml-node.md index 3f0ffcb..a2cfcbd 100644 --- a/docs/design/sysml2-tools-language/semantic/model/sysml-node.md +++ b/docs/design/sysml2-tools-language/semantic/model/sysml-node.md @@ -88,6 +88,12 @@ There are no behavioral methods beyond the inherited `object` members. `SysmlImp `ReferenceResolver` into a `SysmlEdgeKind.Redefinition` edge, and rendered by `GeneralViewLayoutStrategy` as a hollow-triangle-crossbar marker. - `Multiplicity` — the multiplicity text (e.g., `"[4]"`, `"[0..*]"`), or null when unspecified. +- `ExpressionText` — the raw expression text of a constraint-kind feature's calculation body or + referenced constraint name (e.g. `"require constraint"`, `"assume constraint"`, `"constraint"` + keywords), or null for every other feature kind. Mirrors `SysmlTransitionNode.Guard`'s + raw-text-only capture; no expression-tree modeling is attempted. Rendered by + `GeneralViewLayoutStrategy.FormatFeatureRow` in place of the generic `name : Type + [multiplicity]` row shape when non-null. `SysmlConnectionNode` adds: diff --git a/docs/gallery/README.md b/docs/gallery/README.md index 6570ac1..c08c939 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -44,6 +44,15 @@ standalone `dependency FlightController to Battery;` statement demonstrates the same dashed open-chevron line without a label, used for general OMG dependency relationships. +`Battery`'s `doc /* ... */` annotation demonstrates the `BoxShape.Note` +(folded-corner) box rendered for a documented element, connected to its box by +a plain solid line. `FlightMode` is now an `enum def` with literal values +(`idle`/`manual`/`autonomous`), demonstrating the `enum values` compartment. +`FlightTimeRequirement` gains a `subject`/`require constraint` body, +demonstrating the stereotype-titled `«subject»`/`«require constraint»` +compartments (a constraint compartment shows its raw expression text rather +than a `name : Type` row). + Model: [`models/01-drone-general.sysml`](models/01-drone-general.sysml) · SVG: [`svg/DroneGeneralView.svg`](svg/DroneGeneralView.svg) diff --git a/docs/gallery/models/01-drone-general.sysml b/docs/gallery/models/01-drone-general.sysml index 00150a4..31d9530 100644 --- a/docs/gallery/models/01-drone-general.sysml +++ b/docs/gallery/models/01-drone-general.sysml @@ -11,10 +11,20 @@ package QuadcopterDrone { // ===== Attribute and enumeration definitions ===== attribute def Mass; attribute def Voltage; - enum def FlightMode; + + // An enum def with literal values, demonstrating the "enum values" + // compartment populated from AstBuilder's enum-literal capture. + enum def FlightMode { + enum idle; + enum manual; + enum autonomous; + } // ===== Core part definitions ===== part def Battery { + doc /* Rechargeable lithium-polymer battery pack supplying the drone's + * flight controller, motors, and sensor bus via the PowerPort + * interface. Demonstrates the BoxShape.Note annotation rendering. */ attribute capacity : Voltage; port output : PowerPort; } @@ -77,7 +87,13 @@ package QuadcopterDrone { } // ===== A requirement ===== - requirement def FlightTimeRequirement; + // A subject and a require constraint, demonstrating the «subject» and + // «require constraint» stereotype-titled compartments. + requirement def FlightTimeRequirement { + subject drone : Drone; + attribute minFlightTimeMinutes : Mass; + require constraint { drone.totalMass <= 2500 } + } // An allocate relationship from the requirement to the part it is // allocated to, demonstrating the dashed open-chevron line with the diff --git a/docs/gallery/png/BatterySubsystemView.png b/docs/gallery/png/BatterySubsystemView.png index 43f7678..7a6a38c 100644 Binary files a/docs/gallery/png/BatterySubsystemView.png and b/docs/gallery/png/BatterySubsystemView.png differ diff --git a/docs/gallery/png/DroneGeneralView.png b/docs/gallery/png/DroneGeneralView.png index d03431b..05e5409 100644 Binary files a/docs/gallery/png/DroneGeneralView.png and b/docs/gallery/png/DroneGeneralView.png differ diff --git a/docs/gallery/svg/BatterySubsystemView.svg b/docs/gallery/svg/BatterySubsystemView.svg index 249347d..ea5846e 100644 --- a/docs/gallery/svg/BatterySubsystemView.svg +++ b/docs/gallery/svg/BatterySubsystemView.svg @@ -1,4 +1,4 @@ - + @@ -30,16 +30,23 @@ - - «package» - QuadcopterDrone - - «part def» - Battery - - attributes - capacity : Voltage - - ports - output : PowerPort + + «package» + QuadcopterDrone + + «part def» + Battery + + attributes + capacity : Voltage + + ports + output : PowerPort + + + + Rechargeable lithium-polymer battery pack supplying the drone's + * flight controller, motors, and sensor bus via the PowerPort + * interface. Demonstrates the BoxShape.Note annotation rendering. + diff --git a/docs/gallery/svg/DroneGeneralView.svg b/docs/gallery/svg/DroneGeneralView.svg index 93b1d0c..4a2c150 100644 --- a/docs/gallery/svg/DroneGeneralView.svg +++ b/docs/gallery/svg/DroneGeneralView.svg @@ -1,4 +1,4 @@ - + @@ -30,145 +30,167 @@ - - «package» - QuadcopterDrone + + «package» + QuadcopterDrone «interface def» PowerBus - - «interface def» - DataBus - - «port def» - PowerPort - - «port def» - TelemetryPort - - «port def» - MotorControlPort - - «port def» - SensorPort - - «attribute def» - Mass - - «attribute def» - Voltage - - «enum def» - FlightMode - - «part def» - Battery - - attributes - capacity : Voltage - - ports - output : PowerPort - - «part def» - FlightController - - attributes - mode : FlightMode - - ports - power : PowerPort - telemetry : TelemetryPort - motors : MotorControlPort - sensors : SensorPort - - «part def» - Motor - - attributes - maxThrust : Mass - - ports - control : MotorControlPort - - «part def» - Propeller - - «part def» - ImuSensor - - ports - data : SensorPort - - «part def» - GpsSensor - - ports - data : SensorPort - - «part def» - Frame - - «part def» - RacingMotor - - attributes - : Mass - - «part def» - EnduranceBattery - - «part def» - Drone - - attributes - totalMass : Mass - - parts - airframe : Frame - battery : Battery - controller : FlightController - motors : Motor [4] - propellers : Propeller [4] - imu : ImuSensor - gps : GpsSensor - - «part def» - RacingDrone - - parts - frontMotors : Motor [2] - - «requirement def» - FlightTimeRequirement - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - «allocate» + + «interface def» + DataBus + + «port def» + PowerPort + + «port def» + TelemetryPort + + «port def» + MotorControlPort + + «port def» + SensorPort + + «attribute def» + Mass + + «attribute def» + Voltage + + «enum def» + FlightMode + + enum values + idle + manual + autonomous + + «part def» + Battery + + attributes + capacity : Voltage + + ports + output : PowerPort + + + + Rechargeable lithium-polymer battery pack supplying the drone's + * flight controller, motors, and sensor bus via the PowerPort + * interface. Demonstrates the BoxShape.Note annotation rendering. + + «part def» + FlightController + + attributes + mode : FlightMode + + ports + power : PowerPort + telemetry : TelemetryPort + motors : MotorControlPort + sensors : SensorPort + + «part def» + Motor + + attributes + maxThrust : Mass + + ports + control : MotorControlPort + + «part def» + Propeller + + «part def» + ImuSensor + + ports + data : SensorPort + + «part def» + GpsSensor + + ports + data : SensorPort + + «part def» + Frame + + «part def» + RacingMotor + + attributes + maxThrust : Mass + + «part def» + EnduranceBattery + + «part def» + Drone + + attributes + totalMass : Mass + + parts + airframe : Frame + battery : Battery + controller : FlightController + motors : Motor [4] + propellers : Propeller [4] + imu : ImuSensor + gps : GpsSensor + + «part def» + RacingDrone + + parts + frontMotors : Motor [2] + + «requirement def» + FlightTimeRequirement + + «subject» + drone : Drone + + attributes + minFlightTimeMinutes : Mass + + «require constraint» + {drone.totalMass<=2500} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + «allocate» diff --git a/docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml b/docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml index fd89efc..45d9c7b 100644 --- a/docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml +++ b/docs/reqstream/sysml2-tools-core/layout/internal/general-view-layout-strategy.yaml @@ -390,3 +390,47 @@ sections: tests: - GeneralViewLayoutStrategy_BuildLayout_UnresolvableRedefinition_ProducesNoEdge - GeneralViewLayoutStrategy_BuildLayout_SelfReferentialRedefinition_ProducesNoEdge + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-AnnotationNote + title: >- + GeneralViewLayoutStrategy shall render one `BoxShape.Note` box per annotated + definition, connected to the definition's box by a plain solid line with no end + marker, containing the definition's Documentation/Comment annotation text (multiple + annotations concatenated with a blank-line separator into a single note), and shall + emit no note box for a definition with no annotations. + justification: | + The ROADMAP's "Annotating elements" gap: `comment`/`doc` annotations were already + captured into SysmlNode.Annotations by AstBuilder but never rendered anywhere in the + General View, silently discarding real model content. `AddAnnotationNote` implements + this as an ordinary `LayoutGraph` node/edge pair added during the same single-pass + `BuildGraph` traversal that builds each definition's own box — a deliberate, + documented deviation from a literal post-layout marker-decoration approach (as used + by `AddInitialMarker` in the State Transition View), chosen because this strategy + builds its entire graph in one pass with no separate decoration phase. One note per + annotated element (not one note per annotation) keeps the diagram from fragmenting + into many small boxes when an element has both a `doc` and one or more `comment`s. + tests: + - GeneralViewLayoutStrategy_BuildLayout_AnnotatedDefinition_EmitsNoteBox + - GeneralViewLayoutStrategy_BuildLayout_UnannotatedDefinition_EmitsNoNoteBox + - GeneralViewLayoutStrategy_BuildLayout_MultipleAnnotations_ProduceOneNoteBox + + - id: SysML2Tools-Core-Layout-Internal-GeneralViewLayoutStrategy-RequirementConstraintCompartments + title: >- + GeneralViewLayoutStrategy's Pluralize helper shall title a "subject", "assume + constraint", "require constraint", or "constraint" feature compartment with the + guillemet-wrapped stereotype form («subject», «assume constraint», «require + constraint», «constraint») rather than the generic pluralized-keyword form, and + FormatFeatureRow shall render a feature with a non-empty ExpressionText as its raw + expression text (optionally prefixed with "name: ") instead of the generic "name : + Type [multiplicity]" shape. + justification: | + These compartment kinds are new to AstBuilder's output (subject/actor/stakeholder/ + constraint members, enum literal values) and the plain pluralized-keyword default + ("subjects", "assume constraints", ...) does not match the stereotype convention this + file already applies to other individually-significant single-member compartments + (e.g. the pre-existing `«allocate»` edge-label convention reused here), nor would a + generic name/type row correctly convey a constraint's actual expression content. + tests: + - GeneralViewLayoutStrategy_BuildLayout_RequirementSubject_UsesGuillemetTitle + - GeneralViewLayoutStrategy_BuildLayout_ConstraintFeatures_ShowExpressionText + - GeneralViewLayoutStrategy_BuildLayout_EnumDefLiteralValues_ProducesEnumValuesCompartment 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 64a01e9..2dc58f5 100644 --- a/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml +++ b/docs/reqstream/sysml2-tools-language/semantic/model/ast-builder.yaml @@ -337,3 +337,92 @@ sections: - WorkspaceLoader_LoadAsync_AnonymousControlNodes_SynthesizeNames - WorkspaceLoader_LoadAsync_NamedControlNodes_KeepDeclaredName - ControlNode_OmgCorpusFixture_ResolvesForkJoinDecisionMerge + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-EnumerationValues + title: >- + AstBuilder shall build a SysmlFeatureNode (FeatureKeyword "enum value") for each + `enumeratedValue` in an `enum def`'s body, in source order, regardless of whether the + literal uses the bare form, the value-assignment form, or the redefinition-body form. + justification: | + `enumerationBody` uniquely alternates `annotatingMember | enumerationUsageMember` + directly rather than wrapping them in a single rule (unlike `definitionBody`'s + `definitionBodyItem` or `requirementBody`'s `requirementBodyItem`), so the generic + `CollectChildren` helper cannot be applied to it directly. A dedicated + `CollectEnumerationBodyChildren` helper walks the body's raw `context.children`, + filtering to parser-rule children before delegating to `CollectChildren`, so `enum + def` compartments render their literal values instead of always appearing empty. + tests: + - WorkspaceLoader_LoadAsync_EnumDefinition_BareLiterals_CapturesEnumValues + - WorkspaceLoader_LoadAsync_EnumDefinition_ValueAssignmentForm_CapturesNames + - Enumeration_OmgCorpusFixtures_CaptureAllLiteralForms + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-RequirementSubjectAndConstraints + title: >- + AstBuilder shall capture a requirement/concern definition's or usage's `subject`, + `actor`, and `stakeholder` members as minimal SysmlFeatureNodes (FeatureKeyword + "subject", "actor", "stakeholder"), and its `require constraint`/`assume constraint` + members as SysmlFeatureNodes (FeatureKeyword "require constraint"/"assume + constraint") whose ExpressionText captures the constraint's raw expression text. + justification: | + Requirement definitions previously delegated to the generic + BuildDefinitionFromDeclaration helper, which never collected a requirementBody's + Children at all — subject/actor/stakeholder/constraint members were always silently + dropped, defeating the compartment-depth goal for the view's most detailed element + kind. VisitRequirementDefinition and VisitConcernDefinition now push a namespace + scope and call CollectChildren over requirementBody().requirementBodyItem(), + mirroring VisitStateUsage's existing push-scope/body-collect/register pattern. + Because the real OMG training corpus's dominant idiom nests `subject`/`assume + constraint` inside a requirement *usage* that specializes a requirement def (not + inside a `requirement def` body), VisitRequirementUsage and VisitConcernUsage are + also extended to collect Children the same way — a deliberate extension beyond a + literal *Definition-only reading of the ROADMAP item, without which the + compartment-depth goal would not manifest for the corpus's actual idiomatic shape. + BuildConstraintFeatureNode handles both requirementConstraintUsage grammar + alternatives: the reference form (captures the referenced constraint's name into + ExpressionText) and the inline `CONSTRAINT constraintUsageDeclaration + calculationBody` form (captures the raw calculationBody().GetText()). A nested `doc` + inside a constraint's own calculationBody (as seen in the corpus's `assume + constraint { doc /* ... */ ... }` idiom) is intentionally NOT captured — constraint + bodies are captured only as raw expression text, with no nested-member traversal. + tests: + - WorkspaceLoader_LoadAsync_RequirementDefinition_CapturesSubjectAndConstraints + - WorkspaceLoader_LoadAsync_RequirementUsage_CapturesSubjectAndConstraint + - WorkspaceLoader_LoadAsync_RequirementDefinition_CapturesActorAndStakeholder + - WorkspaceLoader_LoadAsync_ConcernDefinitionAndUsage_CapturesSubject + - Requirement_OmgCorpusFixtures_CaptureSubjectAndConstraints + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-StandaloneConstraintUsageAndDefinition + title: >- + AstBuilder shall build a SysmlFeatureNode (FeatureKeyword "constraint") for a + top-level `constraint { expr }` usage, capturing its raw calculation-body expression + text into ExpressionText, and shall synthesize the same shape as a single child + feature of a `constraint def`'s own SysmlDefinitionNode. + justification: | + VisitConstraintUsage was previously entirely absent from AstBuilder — a standalone + `constraint` usage (not nested inside a requirement) produced no AST node at all. + VisitConstraintDefinition previously delegated to BuildDefinitionFromDeclaration, + which builds name/qualifiedName/supertypes correctly for a `constraint def` but has + no way to expose the definition's own calculationBody (constraintDefinition's body is + a calculationBody, not a definitionBody), so it is now rewritten to synthesize a + single "constraint"-keyword child feature node carrying that raw expression text. + tests: + - WorkspaceLoader_LoadAsync_ConstraintUsage_CapturesExpressionText + - WorkspaceLoader_LoadAsync_ConstraintDefinition_SynthesizesExpressionChild + + - id: SysML2Tools-Language-Semantic-Model-AstBuilder-RequirementVerifyFrameNoHoist + title: >- + AstBuilder shall not hoist a requirementVerificationMember's or framedConcernMember's + own nested body content onto the enclosing requirement/concern's Children. + justification: | + Once VisitRequirementDefinition/VisitConcernDefinition/VisitRequirementUsage/ + VisitConcernUsage began calling CollectChildren over requirementBodyItem (needed to + capture subject/constraint members), ANTLR's default last-non-null-wins dispatch + would, for the first time, recurse into a nested `verify`/`frame` member's own + requirementBody and incorrectly bubble its content up as if it were a direct child of + the *outer* enclosing requirement. VisitRequirementVerificationMember and + VisitFramedConcernMember now explicitly return null, short-circuiting that recursive + descent; the verify target itself remains captured separately via the existing + FindVerificationMembers/VerifiedRequirementNames mechanism, unaffected by this + change. + tests: + - WorkspaceLoader_LoadAsync_RequirementVerifyMember_DoesNotHoistNestedContent diff --git a/docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md b/docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md index afb3c86..653f8a5 100644 --- a/docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md +++ b/docs/verification/sysml2-tools-core/layout/internal/general-view-layout-strategy.md @@ -110,6 +110,15 @@ configuration are required beyond a standard .NET SDK installation. `LayoutTree.Warnings` (defense-in-depth diagnostic) — except an unresolved-endpoint drop caused solely by the endpoint falling outside an active `expose` scope narrowing, which is expected behavior and produces no warning. +- Every definition with one or more `Documentation`/`Comment` annotations gets exactly one + `BoxShape.Note` box, connected to the definition's own box by a plain solid line with no end + marker; a definition with no annotations gets no note box. +- A `"subject"`/`"assume constraint"`/`"require constraint"`/`"constraint"`-keyword feature + compartment is titled with the guillemet-wrapped stereotype form (e.g. `«subject»`), not the + generic pluralized-keyword default. +- A constraint-kind feature (non-null `ExpressionText`) renders its raw expression text in place + of the generic `name : Type [multiplicity]` row shape. +- An `"enum value"`-keyword feature compartment is titled `"enum values"`. ##### Test Scenarios @@ -210,3 +219,19 @@ configuration are required beyond a standard .NET SDK installation. - `GeneralViewLayoutStrategy_BuildLayout_SelfReferentialSubsetting_ProducesNoEdge`: A self-referential subsetting reference (resolving back to the subtype's own owning definition) produces no edge +- `GeneralViewLayoutStrategy_BuildLayout_AnnotatedDefinition_EmitsNoteBox`: + A definition with a `Documentation`/`Comment` annotation gets a `BoxShape.Note` box connected by + a plain solid line with no end marker +- `GeneralViewLayoutStrategy_BuildLayout_UnannotatedDefinition_EmitsNoNoteBox`: + A definition with no annotations gets no note box (regression guard) +- `GeneralViewLayoutStrategy_BuildLayout_MultipleAnnotations_ProduceOneNoteBox`: + A definition with multiple `Documentation`/`Comment` annotations gets exactly one note box, with + every annotation's text concatenated into it +- `GeneralViewLayoutStrategy_BuildLayout_RequirementSubject_UsesGuillemetTitle`: + A `"subject"`-keyword feature compartment is titled `«subject»`, not the generic pluralized form +- `GeneralViewLayoutStrategy_BuildLayout_ConstraintFeatures_ShowExpressionText`: + `"require constraint"`/`"assume constraint"` features render their raw `ExpressionText` instead + of a `name : Type [multiplicity]` row, under `«require constraint»`/`«assume constraint»` + compartment titles +- `GeneralViewLayoutStrategy_BuildLayout_EnumDefLiteralValues_ProducesEnumValuesCompartment`: + `"enum value"`-keyword features are grouped under an `"enum values"` compartment title 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 bb3531a..7d2b364 100644 --- a/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md +++ b/docs/verification/sysml2-tools-language/semantic/model/ast-builder.md @@ -72,6 +72,26 @@ external services or additional configuration are required beyond a standard .NE unresolved-reference diagnostics for `start`/`off`/`starting`/`on`, and produces the exact expected declared-state and resolved-transition counts, including entry/do/exit action feature nodes on the `on` state. +- `VisitEnumeratedValue` captures each `enum def` literal (bare, value-assignment, and + redefinition-body forms) as an `"enum value"`-keyword feature, in source order, via the + dedicated `CollectEnumerationBodyChildren` helper. +- `VisitRequirementDefinition`/`VisitConcernDefinition` capture `subject`/`actor`/`stakeholder`/ + `require constraint`/`assume constraint` members as `Children`; `VisitRequirementUsage`/ + `VisitConcernUsage` capture the same members when nested in a requirement/concern *usage* + instead of a definition (the dominant real-corpus idiom). +- A `require constraint`/`assume constraint` member's `ExpressionText` captures its raw + calculation-body expression text, for both the reference form and the inline form. +- `VisitConstraintUsage` (previously entirely absent) captures a top-level `constraint { expr }` + usage's raw expression text; `VisitConstraintDefinition` synthesizes one child feature node + carrying its own calculation-body expression text. +- A `verify`/`frame` member's own nested `requirementBody` content is not spuriously hoisted onto + the enclosing requirement/concern's `Children`, while the verify target itself remains captured + via `VerifiedRequirementNames` — a regression guard for the null-suppression safeguard. +- The real OMG corpus fixtures `training/06.EnumerationDefinitions/EnumerationDefinitions-{1,2}. + sysml`, `training/32.Requirements/RequirementDefinitions.sysml` and `RequirementUsages.sysml`, + and `examples/CommentExamples/Comments.sysml`/`training/01.Packages/DocumentationExample.sysml` + parse with no error diagnostics and produce the expected enum-value/subject/constraint/ + annotation captures. ##### Test Scenarios @@ -120,3 +140,15 @@ external services or additional configuration are required beyond a standard .NE | 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` | +| Enum def bare literals | `WorkspaceLoader_LoadAsync_EnumDefinition_BareLiterals_CapturesEnumValues` | +| Enum def value-assignment form | `WorkspaceLoader_LoadAsync_EnumDefinition_ValueAssignmentForm_CapturesNames` | +| Requirement def subject/constraint | `WorkspaceLoader_LoadAsync_RequirementDefinition_CapturesSubjectAndConstraints` | +| Requirement usage subject/constraint | `WorkspaceLoader_LoadAsync_RequirementUsage_CapturesSubjectAndConstraint` | +| Requirement def actor/stakeholder | `WorkspaceLoader_LoadAsync_RequirementDefinition_CapturesActorAndStakeholder` | +| Concern def/usage subject | `WorkspaceLoader_LoadAsync_ConcernDefinitionAndUsage_CapturesSubject` | +| Standalone constraint usage | `WorkspaceLoader_LoadAsync_ConstraintUsage_CapturesExpressionText` | +| Constraint def synthesized child | `WorkspaceLoader_LoadAsync_ConstraintDefinition_SynthesizesExpressionChild` | +| Verify member no-hoist regression | `WorkspaceLoader_LoadAsync_RequirementVerifyMember_DoesNotHoistNestedContent` | +| Enum def OMG corpus fixtures | `Enumeration_OmgCorpusFixtures_CaptureAllLiteralForms` | +| Requirement OMG corpus fixtures | `Requirement_OmgCorpusFixtures_CaptureSubjectAndConstraints` | +| Comment/documentation OMG corpus fixtures | `CommentAndDocumentation_OmgCorpusFixtures_CaptureAnnotations` | diff --git a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs index 6d006d4..e9d7461 100644 --- a/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs +++ b/src/DemaConsulting.SysML2Tools.Core/Layout/Internal/GeneralViewLayoutStrategy.cs @@ -150,7 +150,8 @@ private sealed record DefBox( IReadOnlyList Memberships, IReadOnlyList Compartments, double Width, - double Height); + double Height, + IReadOnlyList Annotations); /// Where a located definition's node lives: the node itself and its owning package. private readonly record struct Location(LayoutGraphNode Node, string Package); @@ -302,7 +303,7 @@ private static IReadOnlyList CollectDefinitions( var memberships = CollectMemberships(def); var (width, height) = ComputeBoxSize(simpleName, keyword, compartments, theme); - result.Add(new DefBox(qualifiedName, simpleName, keyword, def.SupertypeNames, memberships, compartments, width, height)); + result.Add(new DefBox(qualifiedName, simpleName, keyword, def.SupertypeNames, memberships, compartments, width, height, def.Annotations)); } return result; @@ -342,6 +343,14 @@ private static IReadOnlyList BuildCompartments(SysmlDefinitio /// Formats a usage feature as a compartment row: name : Type [n]. private static string FormatFeatureRow(SysmlFeatureNode feature) { + // A constraint-kind feature's payload is a boolean expression, not a type reference — show + // its raw expression text instead of the generic "name : Type [multiplicity]" shape. Unnamed + // (the corpus-dominant case) shows the expression alone; named shows "name: expression". + if (feature.ExpressionText is { Length: > 0 } expr) + { + return feature.Name is { Length: > 0 } exprName ? $"{exprName}: {expr}" : expr; + } + var name = feature.Name ?? string.Empty; var typing = feature.FeatureTyping is { Length: > 0 } t ? $" : {t}" : string.Empty; var multiplicity = feature.Multiplicity is { Length: > 0 } m ? $" {m}" : string.Empty; @@ -353,6 +362,16 @@ private static string FormatFeatureRow(SysmlFeatureNode feature) private static string Pluralize(string keyword) => keyword switch { "ref" => "references", + + // Stereotype-style titles, per the OMG spec's Graphical Notation chapter conventions — + // reusing this file's existing guillemet convention already established for edge midpoint + // labels (e.g. "«allocate»" in BuildModelEdges), the dominant existing precedent for + // stereotype-style text in this renderer. A requirement conventionally has exactly one + // subject, so "subject" stays singular rather than pluralizing. + "subject" => "«subject»", + "assume constraint" => "«assume constraint»", + "require constraint" => "«require constraint»", + "constraint" => "«constraint»", _ => keyword + "s", }; @@ -910,6 +929,7 @@ private static (LayoutGraph Graph, List Truncated) BuildGraph( { var leaf = MakeDefNode(graph, def); located[def.QualifiedName] = new Location(leaf, package); + AddAnnotationNote(graph, def, leaf, theme); } continue; @@ -946,6 +966,7 @@ private static (LayoutGraph Graph, List Truncated) BuildGraph( { var leaf = MakeDefNode(folder.Children, def); located[def.QualifiedName] = new Location(leaf, package); + AddAnnotationNote(folder.Children, def, leaf, theme); } } @@ -987,6 +1008,81 @@ private static LayoutGraphNode MakeDefNode(LayoutGraph scope, DefBox def) return node; } + /// + /// Emits a companion node (plus a plain connecting edge) for a + /// definition that carries comment/doc annotations, in the same scope the + /// definition's own node was just added to (folder or root — mirrors the existing per-package + /// folder scoping already used for def nodes and model edges). Implemented as an ordinary + /// node/edge (rather than a post-layout coordinate-decoration pass + /// like ) so the existing + /// / can position + /// and route it automatically — a deliberate departure from the ROADMAP's suggested "adapt + /// StateTransitionViewLayoutStrategy.AddInitialMarker's marker-plus-line pattern": that + /// pattern manipulates raw Rect/LayoutNode coordinates after a simpler, + /// non-graph-based layout pass specific to state diagrams, which does not exist in this + /// strategy's architecture (a single handed once to + /// ). Emitting the note as an ordinary graph + /// node/edge is consistent with how every other box/edge in this file is already produced, and + /// lower-risk than hand-computing satellite coordinates in a graph-based layout model that was + /// not designed for post-hoc absolute placement. Does nothing when the definition has no + /// annotations. + /// + private static void AddAnnotationNote(LayoutGraph scope, DefBox def, LayoutGraphNode defNode, Theme theme) + { + if (def.Annotations.Count == 0) + { + return; + } + + var text = BuildNoteText(def.Annotations); + var (width, height, lines) = ComputeNoteBoxSize(text, theme); + + var note = scope.AddNode($"note:{def.QualifiedName}", width, height); + note.Shape = BoxShape.Note; + note.Compartments = [new LayoutCompartment(null, lines)]; + + var edge = scope.AddEdge($"note-edge:{def.QualifiedName}", note, defNode); + edge.TargetEnd = EndMarkerStyle.None; + edge.LineStyle = LineStyle.Solid; + } + + /// + /// Combines an element's comment/documentation annotations into a single note's text: one note + /// box per annotated element, not one per annotation, to avoid uncontrolled box proliferation + /// for well-annotated elements — matches this renderer's existing "one compartment per + /// keyword, not per feature" aggregation philosophy already used in . + /// Multiple annotations are concatenated with a blank-line separator. + /// + private static string BuildNoteText(IReadOnlyList annotations) => + string.Join("\n\n", annotations.Select(a => a.Text.Trim())); + + /// + /// Computes the intrinsic size of a note box from its already-combined text (see + /// ), analogous to but simpler — a + /// note has no title/keyword band, only its own body text rendered as a single untitled + /// compartment's rows. Sizing splits on the text's own newlines only; no word-wrap algorithm + /// is applied (this codebase's DemaConsulting.Rendering dependency has no + /// text-measurement primitive supporting wrapping elsewhere either), so an unusually long + /// single line renders wide — an accepted minimal-implementation limitation. + /// + private static (double Width, double Height, IReadOnlyList Lines) ComputeNoteBoxSize(string text, Theme theme) + { + var lines = text.Split('\n'); + + var width = MinBoxWidth; + foreach (var line in lines) + { + width = Math.Max(width, (line.Length * theme.FontSizeBody * CharWidthFactor) + (3.0 * theme.LabelPadding)); + } + + var height = BoxMetrics.TitleAreaHeight(theme, hasLabel: false, hasKeyword: false) + + theme.LabelPadding + + (lines.Length * (theme.LabelPadding + theme.FontSizeBody)) + + theme.LabelPadding; + + return (width, height, lines); + } + /// /// Replaces each truncated folder's placed box with one carrying its "+N more…" ellipsis label, /// positioned within the box's now-known absolute placement. diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs index 744e2d2..1ad293a 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs @@ -361,7 +361,83 @@ private sealed class MultiNodeCapture : SysmlNode /// public override SysmlNode? VisitEnumerationDefinition(SysMLv2Parser.EnumerationDefinitionContext context) { - return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "enum def"); + var decl = context.definitionDeclaration(); + var name = GetDeclaredName(decl?.identification()); + if (name is null) + { + return null; + } + + var qualifiedName = QualifyName(name); + var supertypeNames = GetSubclassificationSupertypes(decl?.subclassificationPart()); + + // Collect the enumeration body (literal values) as children, mirroring VisitStateDefinition. + // enumerationBody is NOT a single wrapping rule (unlike stateDefBody/definitionBody/ + // requirementBody) — it directly alternates (annotatingMember | enumerationUsageMember)* + // with two distinct context types and no shared wrapper, so recovering source-order + // interleaving of annotations vs. literal values requires walking the raw ANTLR child + // list (see CollectEnumerationBodyChildren) rather than passing a single typed accessor + // array to CollectChildren, as every other body construct in this file does. + _namespaceStack.Add(name); + var (children, annotations) = CollectEnumerationBodyChildren(context.enumerationBody()); + _namespaceStack.RemoveAt(_namespaceStack.Count - 1); + + return new SysmlDefinitionNode + { + Name = name, + QualifiedName = qualifiedName, + DefinitionKeyword = "enum def", + SupertypeNames = supertypeNames, + Children = children, + Annotations = annotations, + }; + } + + /// + /// Collects an enumerationBody's child nodes (literal values and any interleaved + /// comment/documentation annotations), preserving source order. enumerationBody is + /// a narrow, deliberate exception to this file's usual "pass a single typed accessor array + /// to " convention: its grammar rule + /// (LBRACE ( annotatingMember | enumerationUsageMember )* RBRACE) alternates two + /// distinct child context types with no shared wrapping rule (unlike + /// stateBodyItem/definitionBodyItem/requirementBodyItem, which are + /// always wrapped in one rule per body element), so there is no single typed accessor that + /// yields the interleaved sequence. Walking context.children directly (the raw + /// ANTLR child list, filtering out the LBRACE/RBRACE terminals) recovers that + /// order without inventing a new collection mechanism — each remaining child is delegated + /// to the existing generic . + /// + private (IReadOnlyList Children, IReadOnlyList Annotations) + CollectEnumerationBodyChildren(SysMLv2Parser.EnumerationBodyContext? body) + { + if (body is null) + { + return (Array.Empty(), Array.Empty()); + } + + var members = new List(); + for (var i = 0; i < body.ChildCount; i++) + { + // Filter to ParserRuleContext to drop the LBRACE/RBRACE terminals — only + // annotatingMember/enumerationUsageMember contexts should reach CollectChildren. + if (body.GetChild(i) is Antlr4.Runtime.ParserRuleContext member) + { + members.Add(member); + } + } + + return CollectChildren(members); + } + + /// + public override SysmlNode? VisitEnumeratedValue(SysMLv2Parser.EnumeratedValueContext context) + { + // enumeratedValue: ENUM? usage — the exact same usage rule wrapped by + // VisitEnumerationUsage/VisitPartUsage/etc., so BuildUsageNode is reused as-is. "enum + // value" (not the bare "enum" keyword used by VisitEnumerationUsage) keeps an `enum def`'s + // literal-value compartment title ("enum values", via Pluralize's default keyword + "s" + // fallback) distinct from a definition's enum-typed attribute usages. + return BuildUsageNode(context.usage(), "enum value"); } /// @@ -704,19 +780,118 @@ private SysmlFeatureNode BuildActionNodeFeature(SysMLv2Parser.UsageDeclarationCo /// public override SysmlNode? VisitConstraintDefinition(SysMLv2Parser.ConstraintDefinitionContext context) { - return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "constraint def"); + var decl = context.definitionDeclaration(); + var name = GetDeclaredName(decl?.identification()); + if (name is null) + { + return null; + } + + var qualifiedName = QualifyName(name); + var supertypeNames = GetSubclassificationSupertypes(decl?.subclassificationPart()); + + // Synthesize one child SysmlFeatureNode (Name = null, FeatureKeyword = "constraint") + // representing the definition's own calculationBody expression, so the definition's + // constraint expression renders through the same Children-driven compartment mechanism + // (GeneralViewLayoutStrategy.BuildCompartments) used for every other feature-kind + // compartment, rather than adding a second, definition-level-only expression-text field + // and a matching special-cased rendering path. + var expressionText = context.calculationBody()?.GetText(); + IReadOnlyList children = expressionText is null + ? Array.Empty() + : + [ + new SysmlFeatureNode + { + FeatureKeyword = "constraint", + ExpressionText = expressionText, + }, + ]; + + return new SysmlDefinitionNode + { + Name = name, + QualifiedName = qualifiedName, + DefinitionKeyword = "constraint def", + SupertypeNames = supertypeNames, + Children = children, + }; } /// public override SysmlNode? VisitRequirementDefinition(SysMLv2Parser.RequirementDefinitionContext context) { - return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "requirement def"); + var decl = context.definitionDeclaration(); + var name = GetDeclaredName(decl?.identification()); + if (name is null) + { + return null; + } + + var qualifiedName = QualifyName(name); + var supertypeNames = GetSubclassificationSupertypes(decl?.subclassificationPart()); + var body = context.requirementBody(); + + // Collect the requirement body (subject/constraint/actor/stakeholder members, plus any + // ordinary definitionBodyItem alternatives) as children, mirroring VisitStateDefinition. + // requirementBody IS a single wrapping rule (requirementBodyItem), unlike enumerationBody, + // so the generic CollectChildren works unmodified here. + _namespaceStack.Add(name); + var (children, annotations) = CollectChildren(body?.requirementBodyItem() ?? []); + _namespaceStack.RemoveAt(_namespaceStack.Count - 1); + + // VerifiedRequirementNames is still populated by the separate manual-walk mechanism + // (FindVerificationMembers), unaffected by now also collecting Children. + var verifiedRequirementNames = body is not null + ? FindVerificationMembers(body) + : Array.Empty(); + + return new SysmlDefinitionNode + { + Name = name, + QualifiedName = qualifiedName, + DefinitionKeyword = "requirement def", + SupertypeNames = supertypeNames, + VerifiedRequirementNames = verifiedRequirementNames, + Children = children, + Annotations = annotations, + }; } /// public override SysmlNode? VisitConcernDefinition(SysMLv2Parser.ConcernDefinitionContext context) { - return BuildDefinitionFromDeclaration(context.definitionDeclaration(), "concern def"); + // concernDefinition's body is also requirementBody (grammar-confirmed identical shape to + // requirementDefinition), so it gets the same full body-collecting treatment. + var decl = context.definitionDeclaration(); + var name = GetDeclaredName(decl?.identification()); + if (name is null) + { + return null; + } + + var qualifiedName = QualifyName(name); + var supertypeNames = GetSubclassificationSupertypes(decl?.subclassificationPart()); + var body = context.requirementBody(); + + _namespaceStack.Add(name); + var (children, annotations) = CollectChildren(body?.requirementBodyItem() ?? []); + _namespaceStack.RemoveAt(_namespaceStack.Count - 1); + + var verifiedRequirementNames = body is not null + ? FindVerificationMembers(body) + : Array.Empty(); + + return new SysmlDefinitionNode + { + Name = name, + QualifiedName = qualifiedName, + DefinitionKeyword = "concern def", + SupertypeNames = supertypeNames, + VerifiedRequirementNames = verifiedRequirementNames, + Children = children, + Annotations = annotations, + }; } /// @@ -911,13 +1086,29 @@ private SysmlFeatureNode BuildActionNodeFeature(SysMLv2Parser.UsageDeclarationCo /// public override SysmlNode? VisitRequirementUsage(SysMLv2Parser.RequirementUsageContext context) { - // Minimal capture: name/qualified-name only, so that named requirement usages (the common - // real-world satisfy/verify target pattern) become resolvable symbols. Subject/constraint/ - // actor compartment members remain unvisited, consistent with existing scope discipline - // for specialized bodies (see BuildDefinitionFromDeclaration). + // Name/qualified-name capture, so that named requirement usages (the common real-world + // satisfy/verify target pattern) become resolvable symbols. Also collects the requirement + // body's subject/constraint/actor/stakeholder compartment members as Children — the + // dominant real-corpus idiom (a named requirement usage specializing a requirement def and + // supplying its own subject/assume-constraint body) nests these inside a requirement + // *usage*, not only inside a requirement definition's own body, so both must be collected + // for the "compartment depth" goal to be met for the corpus's idiomatic shape. var name = GetDeclaredName(context.constraintUsageDeclaration()?.usageDeclaration()?.identification()); + var body = context.requirementBody(); + + if (name is not null) + { + _namespaceStack.Add(name); + } + + var (children, annotations) = CollectChildren(body?.requirementBodyItem() ?? []); + + if (name is not null) + { + _namespaceStack.RemoveAt(_namespaceStack.Count - 1); + } - var verifiedRequirementNames = context.requirementBody() is { } body + var verifiedRequirementNames = body is not null ? FindVerificationMembers(body) : Array.Empty(); @@ -927,9 +1118,180 @@ private SysmlFeatureNode BuildActionNodeFeature(SysMLv2Parser.UsageDeclarationCo QualifiedName = name is not null ? QualifyName(name) : null, FeatureKeyword = "requirement", VerifiedRequirementNames = verifiedRequirementNames, + Children = children, + Annotations = annotations, }; } + /// + public override SysmlNode? VisitConcernUsage(SysMLv2Parser.ConcernUsageContext context) + { + // concernUsage: occurrenceUsagePrefix CONCERN constraintUsageDeclaration requirementBody — + // identical shape to requirementUsage, so the same name + VerifiedRequirementNames + + // Children capture applies here (see VisitRequirementUsage). + var name = GetDeclaredName(context.constraintUsageDeclaration()?.usageDeclaration()?.identification()); + var body = context.requirementBody(); + + if (name is not null) + { + _namespaceStack.Add(name); + } + + var (children, annotations) = CollectChildren(body?.requirementBodyItem() ?? []); + + if (name is not null) + { + _namespaceStack.RemoveAt(_namespaceStack.Count - 1); + } + + var verifiedRequirementNames = body is not null + ? FindVerificationMembers(body) + : Array.Empty(); + + return new SysmlFeatureNode + { + Name = name, + QualifiedName = name is not null ? QualifyName(name) : null, + FeatureKeyword = "concern", + VerifiedRequirementNames = verifiedRequirementNames, + Children = children, + Annotations = annotations, + }; + } + + /// + public override SysmlNode? VisitSubjectUsage(SysMLv2Parser.SubjectUsageContext context) + { + // subjectUsage: SUBJECT usageExtensionKeyword* usage — reuses the same usage rule as + // VisitPartUsage/VisitEnumerationUsage/etc., so BuildUsageNode is reused as-is. No + // override needed for VisitSubjectMember itself — default ANTLR VisitChildren dispatch + // already descends through memberPrefix (null-returning) into subjectUsage. + return BuildUsageNode(context.usage(), "subject"); + } + + /// + public override SysmlNode? VisitActorUsage(SysMLv2Parser.ActorUsageContext context) + { + // actorUsage: ACTOR usageExtensionKeyword* usage — same trivial reuse as VisitSubjectUsage. + return BuildUsageNode(context.usage(), "actor"); + } + + /// + public override SysmlNode? VisitStakeholderUsage(SysMLv2Parser.StakeholderUsageContext context) + { + // stakeholderUsage: STAKEHOLDER usageExtensionKeyword* usage — same trivial reuse. + return BuildUsageNode(context.usage(), "stakeholder"); + } + + /// + public override SysmlNode? VisitRequirementConstraintMember(SysMLv2Parser.RequirementConstraintMemberContext context) + { + // Must override at the member level (not requirementConstraintUsage) because the emitted + // FeatureKeyword ("assume constraint" vs. "require constraint") depends on the sibling + // requirementKind (ASSUME/REQUIRE token), which only this member context exposes. + var keyword = context.requirementKind()?.REQUIRE() is not null ? "require constraint" : "assume constraint"; + return BuildConstraintFeatureNode(context.requirementConstraintUsage(), keyword); + } + + /// + /// Builds a constraint-kind (FeatureKeyword of + /// "assume constraint"/"require constraint"/"constraint") from a + /// requirementConstraintUsage, handling both grammar alternatives. + /// + /// + /// Alternative 1 (ownedReferenceSubsetting featureSpecializationPart? requirementBody) + /// is a reference to an existing named constraint (e.g. assume + /// myOtherConstraint;): the raw reference text is captured into + /// — a raw reference, not an evaluated + /// expression, when this constraint form is used — and its own nested + /// requirementBody is not recursed into (out of scope). Alternative 2 + /// ((usageExtensionKeyword* CONSTRAINT | usageExtensionKeyword+) + /// constraintUsageDeclaration calculationBody, the corpus-dominant inline form, e.g. + /// require constraint { massActual <= massReqd }) captures the declared name + /// (usually anonymous per corpus) and the raw, unparsed, brace-included + /// calculationBody text into — + /// mirroring 's Guard raw-text-capture precedent. + /// No Children are collected in either case — calculation-body internals + /// (calculationBodyItem/actionBodyItem control nodes) are explicitly out of + /// scope per this unit's "do not model expression trees" boundary. + /// + private SysmlFeatureNode? BuildConstraintFeatureNode( + SysMLv2Parser.RequirementConstraintUsageContext? usage, string keyword) + { + if (usage is null) + { + return null; + } + + if (usage.ownedReferenceSubsetting() is { } reference) + { + return new SysmlFeatureNode + { + FeatureKeyword = keyword, + ExpressionText = reference.GetText(), + }; + } + + var name = GetDeclaredName(usage.constraintUsageDeclaration()?.usageDeclaration()?.identification()); + var expressionText = usage.calculationBody()?.GetText(); + + return new SysmlFeatureNode + { + Name = name, + QualifiedName = name is not null ? QualifyName(name) : null, + FeatureKeyword = keyword, + ExpressionText = expressionText, + }; + } + + /// + public override SysmlNode? VisitConstraintUsage(SysMLv2Parser.ConstraintUsageContext context) + { + // constraintUsage: occurrenceUsagePrefix CONSTRAINT constraintUsageDeclaration + // calculationBody — a top-level, non-nested-in-requirement `constraint { expr }` usage. + // Previously entirely absent from this file (no override at all), so such a usage + // vanished with zero AST trace. Minimal capture: name (usually anonymous per corpus) plus + // the raw calculationBody expression text. + var name = GetDeclaredName(context.constraintUsageDeclaration()?.usageDeclaration()?.identification()); + var expressionText = context.calculationBody()?.GetText(); + + return new SysmlFeatureNode + { + Name = name, + QualifiedName = name is not null ? QualifyName(name) : null, + FeatureKeyword = "constraint", + ExpressionText = expressionText, + }; + } + + /// + public override SysmlNode? VisitRequirementVerificationMember( + SysMLv2Parser.RequirementVerificationMemberContext context) + { + // Newly-required safeguard: now that VisitRequirementDefinition/VisitConcernDefinition + // call CollectChildren over requirementBodyItems, ANTLR's default VisitChildren dispatch + // would, for the first time, recurse into a `verify` member's own nested requirementBody + // (e.g. a nested subjectMember/constraint) — content that would otherwise incorrectly + // bubble up and be added as a child of the OUTER enclosing requirement, via the aggregate- + // last-non-null default dispatch behavior. A `verify` member's target is already captured + // separately by FindVerificationMembers (VerifiedRequirementNames), so this override + // short-circuits to null to suppress the now-newly-reachable recursive descent. Do not + // remove this override without re-verifying that CollectChildren no longer walks + // requirementBodyItem, since removing it would silently corrupt an enclosing requirement's + // Children with spuriously-hoisted nested content. + return null; + } + + /// + public override SysmlNode? VisitFramedConcernMember(SysMLv2Parser.FramedConcernMemberContext context) + { + // Same safeguard as VisitRequirementVerificationMember above: a `frame` member's nested + // concern/calculationBody content is explicitly out of scope and must not be spuriously + // hoisted onto the enclosing requirement's Children now that requirementBodyItems are + // collected. See that override's remarks for the full rationale. + return null; + } + /// /// Recursively scans a parse (sub)tree for /// nodes at any depth, extracting the raw requirement reference name from each. This is a diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs index 91691f3..2cbbb0d 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs @@ -163,6 +163,17 @@ public sealed class SysmlFeatureNode : SysmlNode /// Gets the multiplicity text (e.g., "[4]", "[0..*]"), or null when unspecified. /// public string? Multiplicity { get; init; } + + /// + /// Gets the raw expression text of a constraint/assume constraint/ + /// require constraint feature's calculation body (or, for the reference form of a + /// requirement constraint, the raw reference text of the constraint it points to), or + /// null for features that are not a constraint expression. Captured verbatim, unparsed + /// (mirroring the raw-text-capture precedent) — + /// never evaluated, consistent with this unit's "do not model expression trees" scope + /// boundary. + /// + public string? ExpressionText { get; init; } } /// diff --git a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs index 82983ea..6c16748 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Layout/GeneralViewLayoutStrategyTests.cs @@ -1749,5 +1749,213 @@ public void GeneralViewLayoutStrategy_BuildLayout_SelfReferentialSubsetting_Prod var lines = CollectLines(layout.Nodes); Assert.DoesNotContain(lines, l => l.TargetEnd == EndMarkerStyle.HollowTriangle && l.LineStyle == LineStyle.Dashed); } + + /// + /// BuildLayout emits a companion box (plus a connecting plain + /// line) for a definition that carries a doc/comment annotation. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_AnnotatedDefinition_EmitsNoteBox() + { + // Arrange: a part def with one Documentation annotation + var strategy = new GeneralViewLayoutStrategy(); + var battery = new SysmlDefinitionNode + { + Name = "Battery", + QualifiedName = "P::Battery", + DefinitionKeyword = "part def", + Annotations = [new SysmlAnnotation(SysmlAnnotationKind.Documentation, "Nominal battery capacity is 5000mAh.")], + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::Battery"] = battery } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: a Note-shaped box is present, plus a plain (no-arrowhead, solid) connecting line + var boxes = CollectBoxes(layout.Nodes); + var note = Assert.Single(boxes, b => b.Shape == BoxShape.Note); + Assert.Contains(note.Compartments, c => c.Rows.Any(r => r.Contains("Nominal battery capacity"))); + + var lines = CollectLines(layout.Nodes); + Assert.Contains(lines, l => l.TargetEnd == EndMarkerStyle.None && l.LineStyle == LineStyle.Solid); + } + + /// + /// BuildLayout does not emit a Note box for a definition with no annotations (regression + /// guard against always-on note emission). + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_UnannotatedDefinition_EmitsNoNoteBox() + { + // Arrange: a part def with no annotations + var strategy = new GeneralViewLayoutStrategy(); + var battery = new SysmlDefinitionNode + { + Name = "Battery", + QualifiedName = "P::Battery", + DefinitionKeyword = "part def", + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::Battery"] = battery } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: no Note-shaped box is emitted + var boxes = CollectBoxes(layout.Nodes); + Assert.DoesNotContain(boxes, b => b.Shape == BoxShape.Note); + } + + /// + /// BuildLayout combines multiple annotations on one element into a single Note box (not + /// one box per annotation), per this strategy's documented aggregation choice. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_MultipleAnnotations_ProduceOneNoteBox() + { + // Arrange: a part def with two annotations (a doc and a comment) + var strategy = new GeneralViewLayoutStrategy(); + var battery = new SysmlDefinitionNode + { + Name = "Battery", + QualifiedName = "P::Battery", + DefinitionKeyword = "part def", + Annotations = + [ + new SysmlAnnotation(SysmlAnnotationKind.Documentation, "Primary power source."), + new SysmlAnnotation(SysmlAnnotationKind.Comment, "TODO: revisit capacity."), + ], + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::Battery"] = battery } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert: exactly one Note box, containing both annotations' text + var boxes = CollectBoxes(layout.Nodes); + var note = Assert.Single(boxes, b => b.Shape == BoxShape.Note); + Assert.Contains(note.Compartments, c => c.Rows.Any(r => r.Contains("Primary power source"))); + Assert.Contains(note.Compartments, c => c.Rows.Any(r => r.Contains("revisit capacity"))); + } + + /// + /// BuildLayout renders a requirement definition's subject feature under a + /// stereotype-style «subject» compartment title. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_RequirementSubject_UsesGuillemetTitle() + { + // Arrange: a requirement def owning a subject feature + var strategy = new GeneralViewLayoutStrategy(); + var requirement = new SysmlDefinitionNode + { + Name = "MassLimitationRequirement", + QualifiedName = "P::MassLimitationRequirement", + DefinitionKeyword = "requirement def", + Children = + [ + new SysmlFeatureNode { Name = "vehicle", QualifiedName = "P::MassLimitationRequirement::vehicle", FeatureKeyword = "subject", FeatureTyping = "Vehicle" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::MassLimitationRequirement"] = requirement } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert + var box = CollectBoxes(layout.Nodes).First(b => b.Label == "MassLimitationRequirement"); + Assert.Contains(box.Compartments, c => c.Title == "«subject»" && c.Rows.Contains("vehicle : Vehicle")); + } + + /// + /// BuildLayout renders assume constraint/require constraint/constraint + /// features under stereotype-style compartment titles, showing the raw expression text + /// instead of a name : Type row. + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_ConstraintFeatures_ShowExpressionText() + { + // Arrange: a requirement def owning assume/require constraint features + var strategy = new GeneralViewLayoutStrategy(); + var requirement = new SysmlDefinitionNode + { + Name = "MassLimitationRequirement", + QualifiedName = "P::MassLimitationRequirement", + DefinitionKeyword = "requirement def", + Children = + [ + new SysmlFeatureNode { FeatureKeyword = "assume constraint", ExpressionText = "{fuelMass > 0}" }, + new SysmlFeatureNode { FeatureKeyword = "require constraint", ExpressionText = "{massActual <= massReqd}" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::MassLimitationRequirement"] = requirement } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert + var box = CollectBoxes(layout.Nodes).First(b => b.Label == "MassLimitationRequirement"); + Assert.Contains(box.Compartments, c => c.Title == "«assume constraint»" && c.Rows.Contains("{fuelMass > 0}")); + Assert.Contains(box.Compartments, c => c.Title == "«require constraint»" && c.Rows.Contains("{massActual <= massReqd}")); + } + + /// + /// BuildLayout renders an enum def's literal values under an "enum values" + /// compartment title (the plain default pluralization, not a stereotype). + /// + [Fact] + public void GeneralViewLayoutStrategy_BuildLayout_EnumDefLiteralValues_ProducesEnumValuesCompartment() + { + // Arrange: an enum def owning two enum-value features + var strategy = new GeneralViewLayoutStrategy(); + var flightMode = new SysmlDefinitionNode + { + Name = "FlightMode", + QualifiedName = "P::FlightMode", + DefinitionKeyword = "enum def", + Children = + [ + new SysmlFeatureNode { Name = "manual", QualifiedName = "P::FlightMode::manual", FeatureKeyword = "enum value" }, + new SysmlFeatureNode { Name = "auto", QualifiedName = "P::FlightMode::auto", FeatureKeyword = "enum value" } + ] + }; + var workspace = new SysmlWorkspace + { + Declarations = new Dictionary { ["P::FlightMode"] = flightMode } + }; + var context = new ViewContext("v", workspace); + var options = new RenderOptions(Themes.Light); + + // Act + var layout = strategy.BuildLayout(context, options); + + // Assert + var box = CollectBoxes(layout.Nodes).First(b => b.Label == "FlightMode"); + Assert.Contains(box.Compartments, c => c.Title == "enum values" && c.Rows.Contains("manual") && c.Rows.Contains("auto")); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs index 02de1d7..a3465c4 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Parser/OmgModelsTests.cs @@ -342,4 +342,163 @@ public async Task ControlNode_OmgCorpusFixture_ResolvesForkJoinDecisionMerge() // immediately preceding sibling `J`. Assert.Contains(controlTransitions, t => t.Source == "J" && t.Target == "F"); } + + /// + /// The dedicated 06.EnumerationDefinitions corpus fixtures exercise all three + /// enum def literal forms found in real OMG models: bare literals + /// (enum green;), a redefinition-body literal (unclassified { :>> code = + /// "..."; ... }), and the value-assignment form (A = 4.0;) — confirming + /// CollectEnumerationBodyChildren's narrow raw-children walk (needed because + /// enumerationBody uniquely alternates annotatingMember/ + /// enumerationUsageMember directly, unlike the single-wrapping-rule shape of + /// definitionBody/requirementBody) and VisitEnumeratedValue's + /// "enum value" keyword. + /// + [Fact] + public async Task Enumeration_OmgCorpusFixtures_CaptureAllLiteralForms() + { + var omgRoot = FindOmgModelsRoot(); + var files = new[] + { + Path.Combine(omgRoot, "training", "06.EnumerationDefinitions", "EnumerationDefinitions-1.sysml"), + Path.Combine(omgRoot, "training", "06.EnumerationDefinitions", "EnumerationDefinitions-2.sysml"), + }; + Assert.All(files, f => Assert.True(File.Exists(f), $"Expected fixture not found: {f}")); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync(files, stdlibTable); + + Assert.DoesNotContain(result.Diagnostics, d => d.Severity == DiagnosticSeverity.Error); + + // Bare-literal form. + var trafficLightColor = + (SysmlDefinitionNode)result.Workspace!.Declarations["'Enumeration Definitions-1'::TrafficLightColor"]; + var trafficLightValues = trafficLightColor.Children + .OfType() + .Where(f => f.FeatureKeyword == "enum value") + .Select(f => f.Name) + .ToList(); + Assert.Equal(["green", "yellow", "red"], trafficLightValues); + + // Redefinition-body form (each literal redefines "code"/"color" attributes via `:>>`). + var classificationKind = + (SysmlDefinitionNode)result.Workspace!.Declarations["'Enumeration Definitions-2'::ClassificationKind"]; + var classificationValues = classificationKind.Children + .OfType() + .Where(f => f.FeatureKeyword == "enum value") + .Select(f => f.Name) + .ToList(); + Assert.Equal(["unclassified", "confidential", "secret"], classificationValues); + + // Value-assignment form (`A = 4.0;`) — the assigned value expression is not parsed, only + // the literal's own name, an accepted minimal-capture gap. + var gradePoints = + (SysmlDefinitionNode)result.Workspace!.Declarations["'Enumeration Definitions-2'::GradePoints"]; + var gradeValues = gradePoints.Children + .OfType() + .Where(f => f.FeatureKeyword == "enum value") + .Select(f => f.Name) + .ToList(); + Assert.Equal(["A", "B", "C", "D", "F"], gradeValues); + } + + /// + /// The dedicated 32.Requirements corpus fixtures exercise the requirement + /// compartment-depth idioms found in real OMG models: a requirement def with + /// doc/subject/require constraint/assume constraint members, and + /// — the dominant real-corpus idiom — a requirement *usage* that specializes a + /// requirement def and supplies its own subject/assume constraint body + /// (confirming the deliberate extension of VisitRequirementUsage to collect + /// Children, beyond the plan's literal *Definition-only wording). The nested + /// doc inside RequirementUsages.sysml's assume constraint { doc /* ... */ + /// ... } is confirmed NOT captured (an accepted scope boundary — constraint bodies are + /// captured only as raw ExpressionText, with no nested-member traversal). + /// + [Fact] + public async Task Requirement_OmgCorpusFixtures_CaptureSubjectAndConstraints() + { + var omgRoot = FindOmgModelsRoot(); + var definitionsFile = Path.Combine(omgRoot, "training", "32.Requirements", "RequirementDefinitions.sysml"); + var usagesFile = Path.Combine(omgRoot, "training", "32.Requirements", "RequirementUsages.sysml"); + Assert.All([definitionsFile, usagesFile], f => Assert.True(File.Exists(f), $"Expected fixture not found: {f}")); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([definitionsFile, usagesFile], stdlibTable); + + Assert.DoesNotContain(result.Diagnostics, d => d.Severity == DiagnosticSeverity.Error); + + // RequirementDefinitions.sysml: MassLimitationRequirement has doc + require constraint. + var massLimitation = + (SysmlDefinitionNode)result.Workspace!.Declarations[ + "'Requirement Definitions'::MassLimitationRequirement"]; + Assert.Contains(massLimitation.Annotations, a => a.Kind == SysmlAnnotationKind.Documentation); + var requireConstraint = Assert.Single( + massLimitation.Children.OfType(), f => f.FeatureKeyword == "require constraint"); + Assert.Contains("massActual", requireConstraint.ExpressionText); + Assert.Contains("massReqd", requireConstraint.ExpressionText); + + // VehicleMassLimitationRequirement (a requirement def specializing another) has subject + + // assume constraint. + var vehicleMassLimitation = + (SysmlDefinitionNode)result.Workspace!.Declarations[ + "'Requirement Definitions'::VehicleMassLimitationRequirement"]; + var subject = Assert.Single( + vehicleMassLimitation.Children.OfType(), f => f.FeatureKeyword == "subject"); + Assert.Equal("vehicle", subject.Name); + Assert.Equal("Vehicle", subject.FeatureTyping); + var assumeConstraint = Assert.Single( + vehicleMassLimitation.Children.OfType(), f => f.FeatureKeyword == "assume constraint"); + Assert.Contains("fuelMass", assumeConstraint.ExpressionText); + + // RequirementUsages.sysml: fullVehicleMassLimit is a requirement *usage* specializing + // VehicleMassLimitationRequirement, with its own subject + assume constraint body. + var fullVehicleMassLimit = + (SysmlFeatureNode)result.Workspace!.Declarations["'Requirement Usages'::fullVehicleMassLimit"]; + var usageSubject = Assert.Single( + fullVehicleMassLimit.Children.OfType(), f => f.FeatureKeyword == "subject"); + Assert.Equal("vehicle", usageSubject.Name); + var usageAssumeConstraint = Assert.Single( + fullVehicleMassLimit.Children.OfType(), f => f.FeatureKeyword == "assume constraint"); + Assert.Contains("fuelFullMass", usageAssumeConstraint.ExpressionText); + + // The nested `doc` inside the assume constraint's own calculation body is NOT captured as + // a separate Annotation anywhere on fullVehicleMassLimit — an accepted scope boundary. + Assert.DoesNotContain( + fullVehicleMassLimit.Annotations, a => a.Kind == SysmlAnnotationKind.Documentation); + } + + /// + /// The dedicated 01.Packages/CommentExample.sysml and + /// DocumentationExample.sysml corpus fixtures confirm comment/doc + /// annotations attach to their owning package/definition via the pre-existing + /// AnnotationCapture mechanism — the source of note-box text rendered by + /// GeneralViewLayoutStrategy.AddAnnotationNote. + /// + [Fact] + public async Task CommentAndDocumentation_OmgCorpusFixtures_CaptureAnnotations() + { + var omgRoot = FindOmgModelsRoot(); + var commentsFile = Path.Combine(omgRoot, "examples", "CommentExamples", "Comments.sysml"); + var docFile = Path.Combine(omgRoot, "training", "01.Packages", "DocumentationExample.sysml"); + Assert.All([commentsFile, docFile], f => Assert.True(File.Exists(f), $"Expected fixture not found: {f}")); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([commentsFile, docFile], stdlibTable); + + Assert.DoesNotContain(result.Diagnostics, d => d.Severity == DiagnosticSeverity.Error); + + // Comments.sysml's `part def C { doc /* ... */ comment /* Comment in Part Def */ ... }` + // nests both an annotatingMember doc and comment directly inside C's own body, so both + // attach to C itself (as opposed to the file's `comment about C ...`/`comment about + // Comments ...` forms, which target an element by name via `about` — a reference the + // existing AnnotationCapture mechanism does not resolve; such comments attach to their + // syntactically-owning namespace instead, an existing, unchanged limitation). + var partDefC = (SysmlDefinitionNode)result.Workspace!.Declarations["Comments::C"]; + Assert.Contains(partDefC.Annotations, a => a.Kind == SysmlAnnotationKind.Documentation); + Assert.Contains(partDefC.Annotations, a => a.Kind == SysmlAnnotationKind.Comment); + + var automobileWithDoc = + (SysmlDefinitionNode)result.Workspace!.Declarations["'Documentation Example'::Automobile"]; + Assert.Contains(automobileWithDoc.Annotations, a => a.Kind == SysmlAnnotationKind.Documentation); + } } diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index 02fef40..edb44c7 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -4238,6 +4238,438 @@ action def Flow { } } + /// + /// An enum def with bare literal values captures each as an "enum value" feature, + /// with an implicit source-order-preserving enumerationBody traversal. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_EnumDefinition_BareLiterals_CapturesEnumValues() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package E { + enum def TrafficLightColor { + enum green; + enum yellow; + enum red; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var color = Assert.IsType( + result.Workspace!.Declarations["E::TrafficLightColor"]); + var values = color.Children + .OfType() + .Where(f => f.FeatureKeyword == "enum value") + .Select(f => f.Name) + .ToList(); + Assert.Equal(["green", "yellow", "red"], values); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// An enum def with the value-assignment form (A = 4.0;) still captures each + /// literal's name (the assigned value expression is not parsed, an accepted minimal- + /// capture gap). + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_EnumDefinition_ValueAssignmentForm_CapturesNames() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package E { + private import ScalarValues::Real; + enum def GradePoints :> Real { + A = 4.0; + B = 3.0; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var grades = Assert.IsType( + result.Workspace!.Declarations["E::GradePoints"]); + var values = grades.Children + .OfType() + .Where(f => f.FeatureKeyword == "enum value") + .Select(f => f.Name) + .ToList(); + Assert.Equal(["A", "B"], values); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A requirement definition captures its doc, subject, + /// require constraint, and assume constraint members as Children/Annotations. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_RequirementDefinition_CapturesSubjectAndConstraints() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package R { + private import ScalarValues::Real; + + part def Vehicle { + attribute dryMass : Real; + } + + requirement def MassLimitationRequirement { + doc /* The actual mass shall be less than or equal to the required mass. */ + attribute massActual : Real; + attribute massReqd : Real; + require constraint { massActual <= massReqd } + } + + requirement def VehicleMassLimitationRequirement :> MassLimitationRequirement { + subject vehicle : Vehicle; + assume constraint { vehicle.dryMass > 0 } + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + + var massLimitation = Assert.IsType( + result.Workspace!.Declarations["R::MassLimitationRequirement"]); + Assert.Contains(massLimitation.Annotations, + a => a.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlAnnotationKind.Documentation); + var requireConstraint = Assert.Single(massLimitation.Children + .OfType(), + f => f.FeatureKeyword == "require constraint"); + Assert.Contains("massActual", requireConstraint.ExpressionText); + Assert.Contains("massReqd", requireConstraint.ExpressionText); + + var vehicleRequirement = Assert.IsType( + result.Workspace!.Declarations["R::VehicleMassLimitationRequirement"]); + var subject = Assert.Single(vehicleRequirement.Children + .OfType(), + f => f.FeatureKeyword == "subject"); + Assert.Equal("vehicle", subject.Name); + Assert.Equal("Vehicle", subject.FeatureTyping); + + var assumeConstraint = Assert.Single(vehicleRequirement.Children + .OfType(), + f => f.FeatureKeyword == "assume constraint"); + Assert.Contains("dryMass", assumeConstraint.ExpressionText); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A requirement usage (not only a requirement definition) that specializes a requirement + /// def and supplies its own subject/assume constraint body also captures + /// those members as Children — the dominant real-corpus idiom. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_RequirementUsage_CapturesSubjectAndConstraint() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package R { + private import ScalarValues::Real; + + part def Vehicle { + attribute fuelMass : Real; + } + + requirement def VehicleMassLimitationRequirement; + + requirement fullVehicleMassLimit : VehicleMassLimitationRequirement { + subject vehicle : Vehicle; + assume constraint { + vehicle.fuelMass == 0 + } + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var usage = Assert.IsType( + result.Workspace!.Declarations["R::fullVehicleMassLimit"]); + + var subject = Assert.Single(usage.Children + .OfType(), + f => f.FeatureKeyword == "subject"); + Assert.Equal("vehicle", subject.Name); + + var assumeConstraint = Assert.Single(usage.Children + .OfType(), + f => f.FeatureKeyword == "assume constraint"); + Assert.Contains("fuelMass", assumeConstraint.ExpressionText); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A concern def and a concern usage that specializes it (mirroring the + /// requirement def/usage pattern verified above, since concernBody reuses the same + /// requirementBody grammar shape) also capture their subject member as a + /// Children entry — exercising + /// and . + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_ConcernDefinitionAndUsage_CapturesSubject() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package Cn { + part def Vehicle; + + concern def SafetyConcern { + subject vehicle : Vehicle; + } + + concern vehicleSafety : SafetyConcern { + subject vehicle : Vehicle; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + + var concernDef = Assert.IsType( + result.Workspace!.Declarations["Cn::SafetyConcern"]); + Assert.Equal("concern def", concernDef.DefinitionKeyword); + var defSubject = Assert.Single(concernDef.Children + .OfType(), + f => f.FeatureKeyword == "subject"); + Assert.Equal("vehicle", defSubject.Name); + + var concernUsage = Assert.IsType( + result.Workspace!.Declarations["Cn::vehicleSafety"]); + Assert.Equal("concern", concernUsage.FeatureKeyword); + var usageSubject = Assert.Single(concernUsage.Children + .OfType(), + f => f.FeatureKeyword == "subject"); + Assert.Equal("vehicle", usageSubject.Name); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A top-level constraint { expr } usage (not nested in a requirement) is captured + /// with its raw expression text — previously entirely absent from the AST. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_ConstraintUsage_CapturesExpressionText() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package C { + constraint massCheck { + 1 == 1 + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var constraint = Assert.IsType( + result.Workspace!.Declarations["C::massCheck"]); + Assert.Equal("constraint", constraint.FeatureKeyword); + Assert.Contains("1==1", constraint.ExpressionText); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A constraint def synthesizes one child feature node capturing its own + /// calculation-body expression text, reusing the same Children-driven compartment + /// mechanism as a nested requirement constraint. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_ConstraintDefinition_SynthesizesExpressionChild() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package C { + constraint def MassCheck { + 1 == 1 + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var def = Assert.IsType( + result.Workspace!.Declarations["C::MassCheck"]); + var expressionChild = Assert.Single(def.Children + .OfType()); + Assert.Equal("constraint", expressionChild.FeatureKeyword); + Assert.Contains("1==1", expressionChild.ExpressionText); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// Actor and stakeholder usages nested in a requirement definition's body are captured, + /// mirroring the subject capture pattern. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_RequirementDefinition_CapturesActorAndStakeholder() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package R { + part def Driver; + part def Owner; + + requirement def DriveRequirement { + actor driver : Driver; + stakeholder owner : Owner; + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var req = Assert.IsType( + result.Workspace!.Declarations["R::DriveRequirement"]); + var features = req.Children.OfType().ToList(); + + var actor = Assert.Single(features, f => f.FeatureKeyword == "actor"); + Assert.Equal("driver", actor.Name); + Assert.Equal("Driver", actor.FeatureTyping); + + var stakeholder = Assert.Single(features, f => f.FeatureKeyword == "stakeholder"); + Assert.Equal("owner", stakeholder.Name); + Assert.Equal("Owner", stakeholder.FeatureTyping); + } + finally + { + File.Delete(tempFile); + } + } + + /// + /// A verify member's nested content (its own requirementBody) is not + /// spuriously hoisted onto the enclosing requirement definition's Children — a + /// regression guard for the null-suppression safeguard in + /// VisitRequirementVerificationMember. Its target is still captured separately via + /// VerifiedRequirementNames. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_RequirementVerifyMember_DoesNotHoistNestedContent() + { + var tempFile = Path.GetTempFileName() + ".sysml"; + try + { + await File.WriteAllTextAsync(tempFile, + """ + package R { + part def Vehicle; + + requirement def TargetRequirement { + subject shouldNotAppear : Vehicle; + } + + requirement def CheckMass { + subject checkSubject : Vehicle; + verify requirement notHoisted : TargetRequirement { + subject alsoShouldNotAppear : Vehicle; + } + } + } + """, TestContext.Current.CancellationToken); + + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + Assert.NotNull(result.Workspace); + var checkMass = Assert.IsType( + result.Workspace!.Declarations["R::CheckMass"]); + + // The verify member's own nested subject must not appear anywhere in CheckMass's + // Children — neither as a direct child nor spuriously flattened into one. The + // enclosing requirement's own legitimate "checkSubject" subject must still be present. + var subjects = checkMass.Children + .OfType() + .Where(f => f.FeatureKeyword == "subject") + .ToList(); + var subject = Assert.Single(subjects); + Assert.Equal("checkSubject", subject.Name); + Assert.DoesNotContain(checkMass.Children.OfType(), + f => f.Name is "notHoisted" or "alsoShouldNotAppear"); + + // The verify target is still captured via VerifiedRequirementNames. + Assert.Contains("TargetRequirement", checkMass.VerifiedRequirementNames); + } + finally + { + File.Delete(tempFile); + } + } + /// /// Finds the test/SysMLModels directory relative to the test assembly. ///