Skip to content

Commit ccfe182

Browse files
Fix #186 (#261)
* Fix #186 * Fix wrongly implemented [1] multiplicity
1 parent 5d24445 commit ccfe182

11 files changed

Lines changed: 210 additions & 55 deletions

CLAUDE.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,28 @@ Auto-generated DTOs use structured namespaces reflecting the KerML/SysML package
161161
- Prefer C# property patterns ('x is IType { Prop: value }') over declared-variable-plus-predicate form ('x is IType name && name.Prop == value') when the narrowed variable is only consulted once; the property-pattern form is more concise and intent-revealing
162162
- Surround every braced block (`if`, `else if`, `while`, `for`, `foreach`, `switch`, `using`, `try`/`catch`/`finally`, `lock`, `do…while`, anonymous `{ }`) with a blank line on both sides — the rule does NOT apply at the very start/end of a method body, nor between a `}` and a continuation keyword (`else`, `catch`, `finally`, `while` of `do…while`) that belongs to the same control flow
163163
- When invoking an operation or derived property on a POCO from inside an extension method, call the POCO's instance member (e.g. `subject.IsDistinguishableFrom(other)`, `subject.qualifiedName`), NOT the static `ComputeXxxOperation` / `ComputeXxx` extension method. Virtual dispatch on the POCO honors operation/property REDEFINITION in subclass POCOs; calling the static extension directly bypasses dispatch and silently skips overrides. The static-extension form is reserved EXCLUSIVELY for the C# translation of OCL `self.oclAsType(SuperType).method()` — an explicit upcast that mandates targeting the SuperType's body (e.g. `Usage::namingFeature()``FeatureExtensions.ComputeNamingFeatureOperation(usage)`; `OwningMembership::path()``RelationshipExtensions.ComputeRedefinedPathOperation(owningMembership)`)
164+
- **`IRelationship.OwnedRelatedElement` and `IElement.OwnedRelationship` storage collections are `[0..*]` — NEVER cardinality-limited.** The [1..1] / [0..1] multiplicities that appear in the metamodel apply to *derived* / *redefined* properties (e.g. `OwningMembership::ownedMemberElement`, `FeatureMembership::ownedMemberFeature`, `SubjectMembership::ownedSubjectParameter`), NOT to the underlying storage. When implementing such a derivation, **project from the collection — do not assume positional indexing**. The canonical "in-between" pattern is *filter-by-type-then-validate-count*: project with `OfType<ITargetType>()`, then validate the projection count against the **derived property's declared multiplicity** (read it from the `[Property(lowerValue:…, upperValue:…)]` attribute on the generated POCO interface, or directly from the UML XMI). The failure mode depends on the multiplicity:
165+
166+
| Multiplicity | Empty projection | Single-match projection | 2+ match projection |
167+
|---|---|---|---|
168+
| `[1..1]` (lowerValue=1, upperValue=1) | `throw IncompleteModelException` | return the match | `throw IncompleteModelException` |
169+
| `[0..1]` (lowerValue=0, upperValue=1) | `return null` | return the match | `throw IncompleteModelException` |
170+
| `[0..*]` / `[1..*]` | (use `List<T>` projection; not this pattern) | n/a | n/a |
171+
172+
`IncompleteModelException` is the loud signal to SDK users that the model is malformed — DO NOT swallow it as `null` when the multiplicity is `[1..1]`, and DO NOT raise it for the empty case when the multiplicity is `[0..1]` (a legitimately-optional property).
173+
174+
```csharp
175+
// [1..1] type-narrowed redefinition (e.g. SubjectMembership::ownedSubjectParameter : IUsage)
176+
var matches = subject.OwnedRelatedElement.OfType<ITargetType>().ToList();
177+
178+
return matches.Count == 1
179+
? matches[0]
180+
: throw new IncompleteModelException($"{nameof(subject)} must have exactly one related element of type {nameof(ITargetType)}");
181+
182+
// [1..1] non-narrowing redefinition (e.g. OwningMembership::ownedMemberElement : IElement)
183+
return subject.OwnedRelatedElement.Count == 1
184+
? subject.OwnedRelatedElement[0]
185+
: throw new IncompleteModelException($"{nameof(subject)} must have exactly one related element");
186+
```
187+
188+
Do NOT use `.Count != 1 → throw` followed by `OwnedRelatedElement[0] as ITargetType` — that pattern silently drops the correctly-typed element when it does not sit at index 0 (e.g. when an `IAnnotation` target is also present, since `AssignOwnership` allows owned related elements for both `IOwningMembership` AND `IAnnotation`). Same rule applies to any other derived property that subsets one of these two `[0..*]` storage collections.

SysML2.NET.Tests/Extend/FeatureMembershipExtensionsTestFixture.cs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,27 +41,49 @@ public void VerifyComputeOwnedMemberFeature()
4141
{
4242
Assert.That(() => ((IFeatureMembership)null).ComputeOwnedMemberFeature(), Throws.TypeOf<ArgumentNullException>());
4343

44+
// Empty OwnedRelatedElement → [1..1] violation: throws IncompleteModelException.
4445
var featureMembership = new FeatureMembership();
4546

4647
Assert.That(() => featureMembership.ComputeOwnedMemberFeature(), Throws.TypeOf<IncompleteModelException>());
4748

49+
// Single IFeature wired via the public API → returned.
4850
var owningType = new Type();
4951
var feature = new Feature();
5052

5153
owningType.AssignOwnership(featureMembership, feature);
5254

5355
Assert.That(featureMembership.ComputeOwnedMemberFeature(), Is.SameAs(feature));
5456

55-
// NOTE: wiring a non-IFeature element as the sole OwnedRelatedElement is not possible via the
56-
// public AssignOwnership API (it validates that FeatureMembership requires an IFeature target).
57-
// To cover the as-cast-returns-null path we directly set OwningRelatedElement on a fresh
58-
// membership so that OwnedRelatedElement[0] is a plain Namespace (which is not an IFeature).
57+
// Two IFeatures in OwnedRelatedElement → [1..1] violation: throws IncompleteModelException.
58+
var twoFeatureMembership = new FeatureMembership();
59+
var firstFeature = new Feature();
60+
var secondFeature = new Feature();
61+
62+
((IContainedRelationship)twoFeatureMembership).OwnedRelatedElement.Add(firstFeature);
63+
((IContainedRelationship)twoFeatureMembership).OwnedRelatedElement.Add(secondFeature);
64+
65+
Assert.That(() => twoFeatureMembership.ComputeOwnedMemberFeature(), Throws.TypeOf<IncompleteModelException>());
66+
67+
// Mixed-type owned related elements: exactly one IFeature alongside a non-IFeature (Namespace).
68+
// The OfType<IFeature>() projection MUST pick out the IFeature regardless of its position
69+
// (this is the core robustness guarantee — never positionally index the unfiltered collection).
70+
var mixedMembership = new FeatureMembership();
71+
var siblingNonFeature = new Namespace();
72+
var mixedFeature = new Feature();
73+
74+
((IContainedRelationship)mixedMembership).OwnedRelatedElement.Add(siblingNonFeature);
75+
((IContainedRelationship)mixedMembership).OwnedRelatedElement.Add(mixedFeature);
76+
77+
Assert.That(mixedMembership.ComputeOwnedMemberFeature(), Is.SameAs(mixedFeature));
78+
79+
// OwnedRelatedElement populated with non-IFeature element(s) only → no IFeature match:
80+
// [1..1] violation, throws IncompleteModelException.
5981
var nonFeatureMembership = new FeatureMembership();
6082
var nonFeatureElement = new Namespace();
6183

6284
((IContainedRelationship)nonFeatureMembership).OwnedRelatedElement.Add(nonFeatureElement);
6385

64-
Assert.That(nonFeatureMembership.ComputeOwnedMemberFeature(), Is.Null);
86+
Assert.That(() => nonFeatureMembership.ComputeOwnedMemberFeature(), Throws.TypeOf<IncompleteModelException>());
6587
}
6688

6789
[Test]

SysML2.NET.Tests/Extend/FeatureValueExtensionsTestFixture.cs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ namespace SysML2.NET.Tests.Extend
2929
using SysML2.NET.Core.POCO.Kernel.FeatureValues;
3030
using SysML2.NET.Core.POCO.Root.Elements;
3131
using SysML2.NET.Core.POCO.Root.Namespaces;
32+
using SysML2.NET.Exceptions;
3233
using SysML2.NET.Extensions;
3334

3435
[TestFixture]
@@ -61,6 +62,12 @@ public void VerifyComputeValue()
6162
{
6263
Assert.That(() => ((IFeatureValue)null).ComputeValue(), Throws.TypeOf<ArgumentNullException>());
6364

65+
// Empty OwnedRelatedElement → [1..1] violation: throws IncompleteModelException.
66+
var emptyFeatureValue = new FeatureValue();
67+
68+
Assert.That(() => emptyFeatureValue.ComputeValue(), Throws.TypeOf<IncompleteModelException>());
69+
70+
// Single IExpression wired via the public API → returned.
6471
var feature = new Feature();
6572
var featureValue = new FeatureValue();
6673
var literalBoolean = new LiteralBoolean();
@@ -69,13 +76,36 @@ public void VerifyComputeValue()
6976

7077
Assert.That(featureValue.ComputeValue(), Is.SameAs(literalBoolean));
7178

72-
// Non-Expression owned member: direct field bypass — the cast must return null.
79+
// Two IExpressions in OwnedRelatedElement → [1..1] violation: throws IncompleteModelException.
80+
var twoExprFeatureValue = new FeatureValue();
81+
var firstExpression = new LiteralBoolean();
82+
var secondExpression = new LiteralBoolean();
83+
84+
((IContainedRelationship)twoExprFeatureValue).OwnedRelatedElement.Add(firstExpression);
85+
((IContainedRelationship)twoExprFeatureValue).OwnedRelatedElement.Add(secondExpression);
86+
87+
Assert.That(() => twoExprFeatureValue.ComputeValue(), Throws.TypeOf<IncompleteModelException>());
88+
89+
// Mixed-type owned related elements: exactly one IExpression alongside a non-IExpression (Namespace).
90+
// The OfType<IExpression>() projection MUST pick out the IExpression regardless of its position
91+
// (this is the core robustness guarantee — never positionally index the unfiltered collection).
92+
var mixedFeatureValue = new FeatureValue();
93+
var siblingNonExpression = new Namespace();
94+
var mixedExpression = new LiteralBoolean();
95+
96+
((IContainedRelationship)mixedFeatureValue).OwnedRelatedElement.Add(siblingNonExpression);
97+
((IContainedRelationship)mixedFeatureValue).OwnedRelatedElement.Add(mixedExpression);
98+
99+
Assert.That(mixedFeatureValue.ComputeValue(), Is.SameAs(mixedExpression));
100+
101+
// OwnedRelatedElement populated with non-IExpression element(s) only → no IExpression match:
102+
// [1..1] violation, throws IncompleteModelException.
73103
var nonExprFeatureValue = new FeatureValue();
74104
var nonExprElement = new Namespace();
75105

76106
((IContainedRelationship)nonExprFeatureValue).OwnedRelatedElement.Add(nonExprElement);
77107

78-
Assert.That(nonExprFeatureValue.ComputeValue(), Is.Null);
108+
Assert.That(() => nonExprFeatureValue.ComputeValue(), Throws.TypeOf<IncompleteModelException>());
79109
}
80110
}
81111
}

SysML2.NET.Tests/Extend/ParameterMembershipExtensionsTestFixture.cs

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,37 +43,48 @@ public void VerifyComputeOwnedMemberParameter()
4343
{
4444
Assert.That(() => ((IParameterMembership)null).ComputeOwnedMemberParameter(), Throws.TypeOf<ArgumentNullException>());
4545

46+
// Empty OwnedRelatedElement → [1..1] violation: throws IncompleteModelException.
4647
var parameterMembership = new ParameterMembership();
4748

4849
Assert.That(() => parameterMembership.ComputeOwnedMemberParameter(), Throws.TypeOf<IncompleteModelException>());
4950

51+
// Single IFeature wired via the public API → returned.
5052
var owningType = new Type();
5153
var feature = new Feature();
5254

5355
owningType.AssignOwnership(parameterMembership, feature);
5456

5557
Assert.That(parameterMembership.ComputeOwnedMemberParameter(), Is.SameAs(feature));
5658

57-
// Wiring two features to verify the multiple-element guard:
58-
// First remove the existing wiring so we can create a fresh membership with two elements.
59-
var twoElementMembership = new ParameterMembership();
59+
// Two IFeatures in OwnedRelatedElement → [1..1] violation: throws IncompleteModelException.
60+
var twoFeatureMembership = new ParameterMembership();
6061
var secondFeature = new Feature();
6162

62-
((IContainedRelationship)twoElementMembership).OwnedRelatedElement.Add(feature);
63-
((IContainedRelationship)twoElementMembership).OwnedRelatedElement.Add(secondFeature);
63+
((IContainedRelationship)twoFeatureMembership).OwnedRelatedElement.Add(feature);
64+
((IContainedRelationship)twoFeatureMembership).OwnedRelatedElement.Add(secondFeature);
6465

65-
Assert.That(() => twoElementMembership.ComputeOwnedMemberParameter(), Throws.TypeOf<IncompleteModelException>());
66+
Assert.That(() => twoFeatureMembership.ComputeOwnedMemberParameter(), Throws.TypeOf<IncompleteModelException>());
6667

67-
// NOTE: wiring a non-IFeature element as the sole OwnedRelatedElement is not possible via the
68-
// public AssignOwnership API (IParameterMembership requires an IFeature target).
69-
// To cover the as-cast-returns-null path we directly populate OwnedRelatedElement with a
70-
// plain Namespace (which is not an IFeature).
68+
// Mixed-type owned related elements: exactly one IFeature alongside a non-IFeature (Namespace).
69+
// The OfType<IFeature>() projection MUST pick out the IFeature regardless of its position
70+
// (this is the core robustness guarantee — never positionally index the unfiltered collection).
71+
var mixedMembership = new ParameterMembership();
72+
var siblingNonFeature = new Namespace();
73+
var mixedFeature = new Feature();
74+
75+
((IContainedRelationship)mixedMembership).OwnedRelatedElement.Add(siblingNonFeature);
76+
((IContainedRelationship)mixedMembership).OwnedRelatedElement.Add(mixedFeature);
77+
78+
Assert.That(mixedMembership.ComputeOwnedMemberParameter(), Is.SameAs(mixedFeature));
79+
80+
// OwnedRelatedElement populated with non-IFeature element(s) only → no IFeature match:
81+
// [1..1] violation, throws IncompleteModelException.
7182
var nonFeatureMembership = new ParameterMembership();
7283
var nonFeatureElement = new Namespace();
7384

7485
((IContainedRelationship)nonFeatureMembership).OwnedRelatedElement.Add(nonFeatureElement);
7586

76-
Assert.That(nonFeatureMembership.ComputeOwnedMemberParameter(), Is.Null);
87+
Assert.That(() => nonFeatureMembership.ComputeOwnedMemberParameter(), Throws.TypeOf<IncompleteModelException>());
7788
}
7889

7990
[Test]

SysML2.NET.Tests/Extend/RequirementDefinitionExtensionsTestFixture.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,15 @@ public void VerifyComputeSubjectParameter()
196196

197197
Assert.That(requirementDefinition.ComputeSubjectParameter(), Is.Null);
198198

199-
// Populated case: SubjectMembership is present; selecting ownedSubjectParameter triggers an
200-
// upstream stub (SubjectMembershipExtensions.ComputeOwnedSubjectParameter is not yet implemented).
199+
// Populated case: SubjectMembership is present alongside the earlier ParameterMembership.
200+
// OfType<ISubjectMembership> must discriminate — only the subject's ownedSubjectParameter surfaces.
201+
// This also covers the mixed-state discrimination: both a ParameterMembership and a SubjectMembership
202+
// are wired; the result must be the subject usage, not the parameter usage.
201203
var subjectMembership = new SubjectMembership();
202204
var subjectUsage = new Usage();
203205
requirementDefinition.AssignOwnership(subjectMembership, subjectUsage);
204206

205-
Assert.That(() => requirementDefinition.ComputeSubjectParameter(), Throws.TypeOf<NotSupportedException>());
207+
Assert.That(requirementDefinition.ComputeSubjectParameter(), Is.SameAs(subjectUsage));
206208
}
207209

208210
private static readonly string[] ExpectedSingleComputedText = ["The requirement text."];

SysML2.NET.Tests/Extend/RequirementUsageExtensionsTestFixture.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,17 +229,19 @@ public void VerifyComputeSubjectParameter()
229229

230230
Assert.That(requirementUsage.ComputeSubjectParameter(), Is.Null);
231231

232-
// Populated case: SubjectMembership is present; selecting ownedSubjectParameter triggers an
233-
// upstream stub (SubjectMembershipExtensions.ComputeOwnedSubjectParameter is not yet implemented).
232+
// Populated case: SubjectMembership is present alongside the earlier ParameterMembership.
233+
// OfType<ISubjectMembership> must discriminate — only the subject's ownedSubjectParameter surfaces.
234+
// This also covers the mixed-state discrimination: both a ParameterMembership and a SubjectMembership
235+
// are wired; the result must be the subject usage, not the parameter usage.
234236
var subjectMembership = new SubjectMembership();
235237
var subjectUsage = new Usage();
236238
requirementUsage.AssignOwnership(subjectMembership, subjectUsage);
237239

238-
Assert.That(() => requirementUsage.ComputeSubjectParameter(), Throws.TypeOf<NotSupportedException>());
240+
Assert.That(requirementUsage.ComputeSubjectParameter(), Is.SameAs(subjectUsage));
239241
}
240242

241-
private static readonly string[] ExpectedSingleComputedText = new[] { "The requirement text." };
242-
private static readonly string[] ExpectedMultipleComputedText = new[] { "The requirement text.", "Additional context." };
243+
private static readonly string[] ExpectedSingleComputedText = ["The requirement text."];
244+
private static readonly string[] ExpectedMultipleComputedText = ["The requirement text.", "Additional context."];
243245

244246
[Test]
245247
public void VerifyComputeText()

0 commit comments

Comments
 (0)