diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 6e572cc616..918c67cc7f 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -83,7 +83,11 @@ A requirement derivation is a `ConnectionUsage` annotated with the `#derivation` The edge goes from the derived requirement to the original one and is displayed as a dashed `«derive»` line, so it can be distinguished from a `«satisfy»` edge. - https://github.com/eclipse-syson/syson/issues/2403[#2403] [diagrams] Add support for the inheritance of `ItemUsage` border nodes on `ActionDefinition` and `ActionUsage` graphical nodes. - https://github.com/eclipse-syson/syson/issues/2404[#2404] [diagrams] Add support for the inheritance of `ItemUsage` border nodes on `PortDefinition` and `PortUsage` graphical nodes. +- https://github.com/eclipse-syson/syson/issues/2428[#2428] [diagrams] Add the _New Derived Requirement_ edge tool on `RequirementUsage` graphical nodes, creating a requirement derivation towards another `RequirementUsage`. +The edge is drawn from the derived requirement to the original one, and the created derivation has the same shape as one written in text: a `ConnectionUsage` annotated with `#derivation`, whose ends are annotated with `#original` and `#derive`. +The import of the `RequirementDerivation` library is added to the owning namespace when it is missing. +== v20 == v2026.7.0 === Breaking changes diff --git a/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVRequirementDerivationCreationTests.java b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVRequirementDerivationCreationTests.java new file mode 100644 index 0000000000..05c6bc8c39 --- /dev/null +++ b/backend/application/syson-application/src/test/java/org/eclipse/syson/application/controllers/diagrams/general/view/GVRequirementDerivationCreationTests.java @@ -0,0 +1,342 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.application.controllers.diagrams.general.view; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.sirius.components.diagrams.tests.DiagramEventPayloadConsumer.assertRefreshedDiagramThat; +import static org.eclipse.sirius.components.diagrams.tests.assertions.DiagramInstanceOfAssertFactories.EDGE; + +import com.jayway.jsonpath.JsonPath; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +import org.eclipse.sirius.components.collaborative.diagrams.dto.DiagramEventInput; +import org.eclipse.sirius.components.collaborative.diagrams.dto.DiagramRefreshedEventPayload; +import org.eclipse.sirius.components.core.api.IEditingContext; +import org.eclipse.sirius.components.core.api.IIdentityService; +import org.eclipse.sirius.components.core.api.IInput; +import org.eclipse.sirius.components.core.api.IObjectSearchService; +import org.eclipse.sirius.components.core.api.IPayload; +import org.eclipse.sirius.components.core.api.SuccessPayload; +import org.eclipse.sirius.components.diagrams.Diagram; +import org.eclipse.sirius.components.diagrams.ViewModifier; +import org.eclipse.sirius.components.graphql.tests.ExecuteEditingContextFunctionInput; +import org.eclipse.sirius.components.graphql.tests.ExecuteEditingContextFunctionRunner; +import org.eclipse.sirius.components.graphql.tests.ExecuteEditingContextFunctionSuccessPayload; +import org.eclipse.sirius.components.view.emf.diagram.IDiagramIdProvider; +import org.eclipse.sirius.web.tests.services.api.IGivenInitialServerState; +import org.eclipse.syson.AbstractIntegrationTests; +import org.eclipse.syson.application.controllers.diagrams.testers.DropFromExplorerTester; +import org.eclipse.syson.application.controllers.diagrams.testers.EdgeCreationTester; +import org.eclipse.syson.application.data.GeneralViewEmptyTestProjectData; +import org.eclipse.syson.application.imports.MutationInsertTextualSysMLv2DataRunner; +import org.eclipse.syson.services.diagrams.DiagramDescriptionIdProvider; +import org.eclipse.syson.services.diagrams.api.IGivenDiagramDescription; +import org.eclipse.syson.services.diagrams.api.IGivenDiagramSubscription; +import org.eclipse.syson.standard.diagrams.view.SDVDescriptionNameGenerator; +import org.eclipse.syson.sysml.ConnectionUsage; +import org.eclipse.syson.sysml.Element; +import org.eclipse.syson.sysml.MetadataUsage; +import org.eclipse.syson.sysml.NamespaceImport; +import org.eclipse.syson.sysml.OwningMembership; +import org.eclipse.syson.sysml.Package; +import org.eclipse.syson.sysml.RequirementUsage; +import org.eclipse.syson.sysml.SysmlPackage; +import org.eclipse.syson.sysml.dto.InsertTextualSysMLv2Input; +import org.eclipse.syson.tests.api.GivenSysONServer; +import org.eclipse.syson.util.IDescriptionNameGenerator; +import org.eclipse.syson.util.SysONRepresentationDescriptionIdentifiers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +/** + * Tests the creation of a requirement derivation from the General View. + * + * @author kabayama + */ +@Transactional +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class GVRequirementDerivationCreationTests extends AbstractIntegrationTests { + + private static final String ORIGINAL_REQUIREMENT_NAME = "article5Compliance"; + + private static final String DERIVED_REQUIREMENT_NAME = "paymentDelayPrevention"; + + private static final String CREATION_TOOL_NAME = "New Derived Requirement"; + + private static final String DERIVATION_METADATA = "RequirementDerivation::DerivationMetadata"; + + private static final String ORIGINAL_END_METADATA = "RequirementDerivation::OriginalRequirementMetadata"; + + private static final String DERIVED_END_METADATA = "RequirementDerivation::DerivedRequirementMetadata"; + + /** + * Two requirements without any derivation between them, and without importing the RequirementDerivation library, + * so that the tool has to add that import itself. + */ + private static final String TWO_REQUIREMENTS = """ + requirement %s; + requirement %s; + """.formatted(ORIGINAL_REQUIREMENT_NAME, DERIVED_REQUIREMENT_NAME); + + @Autowired + private IGivenInitialServerState givenInitialServerState; + + @Autowired + private IGivenDiagramSubscription givenDiagramSubscription; + + @Autowired + private IGivenDiagramDescription givenDiagramDescription; + + @Autowired + private IDiagramIdProvider diagramIdProvider; + + @Autowired + private DropFromExplorerTester dropFromExplorerTester; + + @Autowired + private EdgeCreationTester edgeCreationTester; + + @Autowired + private MutationInsertTextualSysMLv2DataRunner insertTextRunner; + + @Autowired + private ExecuteEditingContextFunctionRunner executeEditingContextFunctionRunner; + + @Autowired + private IObjectSearchService objectSearchService; + + @Autowired + private IIdentityService identityService; + + private final IDescriptionNameGenerator descriptionNameGenerator = new SDVDescriptionNameGenerator(); + + @BeforeEach + public void setUp() { + this.givenInitialServerState.initialize(); + } + + private Flux givenSubscriptionToDiagram() { + var diagramEventInput = new DiagramEventInput(UUID.randomUUID(), GeneralViewEmptyTestProjectData.EDITING_CONTEXT, + GeneralViewEmptyTestProjectData.GraphicalIds.DIAGRAM_ID); + return this.givenDiagramSubscription.subscribe(diagramEventInput); + } + + @DisplayName("GIVEN two requirements displayed in a General View, WHEN using the requirement derivation edge tool between them, THEN a derive edge is created from the derived requirement to the original one") + @GivenSysONServer({ GeneralViewEmptyTestProjectData.SCRIPT_PATH }) + @Test + public void createRequirementDerivationFromTheDiagram() { + this.insertText(GeneralViewEmptyTestProjectData.SemanticIds.PACKAGE_1_ID, TWO_REQUIREMENTS); + var originalRequirementId = this.getRequirementId(ORIGINAL_REQUIREMENT_NAME); + var derivedRequirementId = this.getRequirementId(DERIVED_REQUIREMENT_NAME); + + var diagramDescription = this.givenDiagramDescription.getDiagramDescription(GeneralViewEmptyTestProjectData.EDITING_CONTEXT, + SysONRepresentationDescriptionIdentifiers.GENERAL_VIEW_DIAGRAM_DESCRIPTION_ID); + var diagramDescriptionIdProvider = new DiagramDescriptionIdProvider(diagramDescription, this.diagramIdProvider); + var creationToolId = diagramDescriptionIdProvider.getEdgeCreationToolId( + this.descriptionNameGenerator.getNodeName(SysmlPackage.eINSTANCE.getRequirementUsage()), CREATION_TOOL_NAME); + + var flux = this.givenSubscriptionToDiagram(); + + AtomicReference diagram = new AtomicReference<>(); + AtomicReference originalNodeId = new AtomicReference<>(); + + Consumer initialDiagramContentConsumer = assertRefreshedDiagramThat(diagram::set); + + Runnable dropOriginalRequirement = () -> this.dropFromExplorerTester.dropFromExplorerOnDiagram( + GeneralViewEmptyTestProjectData.EDITING_CONTEXT, diagram, originalRequirementId); + + Consumer afterFirstDropConsumer = assertRefreshedDiagramThat(newDiagram -> { + originalNodeId.set(newDiagram.getNodes().get(0).getId()); + diagram.set(newDiagram); + }); + + Runnable dropDerivedRequirement = () -> this.dropFromExplorerTester.dropFromExplorerOnDiagram( + GeneralViewEmptyTestProjectData.EDITING_CONTEXT, diagram, derivedRequirementId); + + AtomicReference derivedNodeId = new AtomicReference<>(); + Consumer afterSecondDropConsumer = assertRefreshedDiagramThat(newDiagram -> { + assertThat(newDiagram.getNodes()).hasSize(2); + derivedNodeId.set(newDiagram.getNodes().stream() + .map(node -> node.getId()) + .filter(nodeId -> !nodeId.equals(originalNodeId.get())) + .findFirst() + .orElseThrow()); + // No derivation exists yet, the tool has not been used. + assertThat(this.visibleEdges(newDiagram)).isEmpty(); + diagram.set(newDiagram); + }); + + // The edge is drawn from the derived requirement to the original one, the direction it is displayed in. + Runnable creationToolRunnable = () -> this.edgeCreationTester.createEdgeUsingNodeId( + GeneralViewEmptyTestProjectData.EDITING_CONTEXT, diagram, derivedNodeId.get(), originalNodeId.get(), creationToolId); + + Consumer afterCreationConsumer = assertRefreshedDiagramThat(newDiagram -> { + var newVisibleEdges = this.visibleEdges(newDiagram); + assertThat(newVisibleEdges).hasSize(1).first(EDGE) + .hasSourceId(derivedNodeId.get()) + .hasTargetId(originalNodeId.get()); + }); + + StepVerifier.create(flux) + .consumeNextWith(initialDiagramContentConsumer) + .then(dropOriginalRequirement) + .consumeNextWith(afterFirstDropConsumer) + .then(dropDerivedRequirement) + .consumeNextWith(afterSecondDropConsumer) + .then(creationToolRunnable) + .consumeNextWith(afterCreationConsumer) + .thenCancel() + .verify(Duration.ofSeconds(30)); + + this.checkCreatedDerivation(); + } + + /** + * Checks the semantic model produced by the tool: a connection annotated with the derivation metadata, whose ends + * are annotated and reference the two requirements, and the import of the library the metadata come from. + */ + private void checkCreatedDerivation() { + BiFunction function = (editingContext, input) -> { + var report = new StringBuilder(); + this.objectSearchService.getObject(editingContext, GeneralViewEmptyTestProjectData.SemanticIds.PACKAGE_1_ID) + .filter(Package.class::isInstance) + .map(Package.class::cast) + .ifPresent(rootPackage -> { + rootPackage.getOwnedElement().stream() + .filter(ConnectionUsage.class::isInstance) + .map(ConnectionUsage.class::cast) + .forEach(connection -> { + report.append("connection=").append(this.appliedMetadataOf(connection)); + connection.getConnectorEnd().forEach(end -> report.append(" end=").append(this.appliedMetadataOf(end)) + .append("->").append(this.referencedNameOf(end))); + }); + report.append(" imports="); + rootPackage.getOwnedImport().stream() + .filter(NamespaceImport.class::isInstance) + .map(NamespaceImport.class::cast) + .map(NamespaceImport::getImportedNamespace) + .filter(Objects::nonNull) + .forEach(namespace -> report.append(namespace.getName()).append(",")); + }); + return new ExecuteEditingContextFunctionSuccessPayload(input.id(), report.toString()); + }; + + var mono = this.executeEditingContextFunctionRunner + .execute(new ExecuteEditingContextFunctionInput(UUID.randomUUID(), GeneralViewEmptyTestProjectData.EDITING_CONTEXT, function)); + var report = Optional.ofNullable(mono.block(Duration.ofSeconds(20))) + .filter(ExecuteEditingContextFunctionSuccessPayload.class::isInstance) + .map(ExecuteEditingContextFunctionSuccessPayload.class::cast) + .map(ExecuteEditingContextFunctionSuccessPayload::result) + .map(String::valueOf) + .orElse(""); + + assertThat(report) + .as("The created connection should be a requirement derivation") + .contains("connection=[" + DERIVATION_METADATA + "]"); + assertThat(report) + .as("The original end should be annotated and reference the original requirement") + .contains("end=[" + ORIGINAL_END_METADATA + "]->" + ORIGINAL_REQUIREMENT_NAME); + assertThat(report) + .as("The derived end should be annotated and reference the derived requirement") + .contains("end=[" + DERIVED_END_METADATA + "]->" + DERIVED_REQUIREMENT_NAME); + assertThat(report) + .as("The library owning the metadata should have been imported") + .contains("RequirementDerivation"); + } + + /** + * The name of the element referenced by a connection end. + */ + private String referencedNameOf(org.eclipse.syson.sysml.Feature end) { + var referenceSubsetting = end.getOwnedReferenceSubsetting(); + if (referenceSubsetting == null || referenceSubsetting.getSubsettedFeature() == null) { + return "null"; + } + return referenceSubsetting.getSubsettedFeature().getName(); + } + + private String appliedMetadataOf(Element element) { + var names = new StringBuilder("["); + element.getOwnedRelationship().stream() + .filter(OwningMembership.class::isInstance) + .map(OwningMembership.class::cast) + .flatMap(membership -> membership.getOwnedRelatedElement().stream()) + .filter(MetadataUsage.class::isInstance) + .map(MetadataUsage.class::cast) + .map(MetadataUsage::getMetadataDefinition) + .filter(Objects::nonNull) + .forEach(definition -> names.append(definition.getQualifiedName())); + return names.append("]").toString(); + } + + private List visibleEdges(Diagram diagram) { + return diagram.getEdges().stream() + .filter(edge -> edge.getState() != ViewModifier.Hidden) + .toList(); + } + + private void insertText(String parentElementId, String content) { + var input = new InsertTextualSysMLv2Input(UUID.randomUUID(), GeneralViewEmptyTestProjectData.EDITING_CONTEXT, parentElementId, content); + var result = this.insertTextRunner.run(input); + + Map parsed = JsonPath.read(result.data(), "$.data.insertTextualSysMLv2"); + assertThat(parsed.get("__typename")).isEqualTo(SuccessPayload.class.getSimpleName()); + assertThat((List) parsed.get("messages")).isEmpty(); + } + + private String getRequirementId(String requirementName) { + BiFunction function = (editingContext, input) -> { + var requirementId = this.objectSearchService.getObject(editingContext, GeneralViewEmptyTestProjectData.SemanticIds.PACKAGE_1_ID) + .filter(Package.class::isInstance) + .map(Package.class::cast) + .stream() + .flatMap(rootPackage -> rootPackage.getOwnedElement().stream()) + .filter(RequirementUsage.class::isInstance) + .filter(requirement -> requirementName.equals(requirement.getName())) + .map(this.identityService::getId) + .findFirst() + .orElse(null); + return new ExecuteEditingContextFunctionSuccessPayload(input.id(), requirementId); + }; + + var mono = this.executeEditingContextFunctionRunner + .execute(new ExecuteEditingContextFunctionInput(UUID.randomUUID(), GeneralViewEmptyTestProjectData.EDITING_CONTEXT, function)); + + var identifier = Optional.ofNullable(mono.block(Duration.ofSeconds(10))) + .filter(ExecuteEditingContextFunctionSuccessPayload.class::isInstance) + .map(ExecuteEditingContextFunctionSuccessPayload.class::cast) + .map(ExecuteEditingContextFunctionSuccessPayload::result) + .filter(String.class::isInstance) + .map(String.class::cast) + .orElse(null); + + assertThat(identifier).as("The requirement %s should have been created", requirementName).isNotNull(); + return identifier; + } +} diff --git a/backend/services/syson-model-services/src/main/java/org/eclipse/syson/model/services/ModelMutationElementService.java b/backend/services/syson-model-services/src/main/java/org/eclipse/syson/model/services/ModelMutationElementService.java index 3aa953add5..ec88b380b8 100644 --- a/backend/services/syson-model-services/src/main/java/org/eclipse/syson/model/services/ModelMutationElementService.java +++ b/backend/services/syson-model-services/src/main/java/org/eclipse/syson/model/services/ModelMutationElementService.java @@ -36,6 +36,8 @@ import org.eclipse.syson.sysml.Type; import org.eclipse.syson.sysml.ViewDefinition; import org.eclipse.syson.sysml.ViewUsage; +import org.eclipse.syson.services.ImportService; +import org.eclipse.syson.services.UtilService; import org.eclipse.syson.sysml.metamodel.helper.EMFUtils; import org.eclipse.syson.sysml.metamodel.services.MetamodelMutationElementService; import org.eclipse.syson.sysml.metamodel.util.ElementUtil; @@ -56,9 +58,46 @@ public class ModelMutationElementService { private final MetamodelMutationElementService metamodelMutationElementService; + private final UtilService utilService; + + private final ImportService importService; + public ModelMutationElementService() { this.metamodelMutationElementService = new MetamodelMutationElementService(); this.elementUtil = new ElementUtil(); + this.utilService = new UtilService(); + this.importService = new ImportService(); + } + + /** + * Create a requirement derivation between two {@link RequirementUsage}, the way it is written in text with + * {@code #derivation connection { end #original ::> ...; end #derive ::> ...; }}. + *

+ * The metadata identifying the derivation and its ends come from the {@code RequirementDerivation} standard + * library, so the import of that library is added to the owning namespace when it is missing. + *

+ * + * @param derived + * the requirement derived from {@code original}, the source of the edge + * @param original + * the requirement {@code derived} is derived from, the target of the edge + * @return the new derivation, or {@code derived} when the standard library could not be reached + */ + public Element createRequirementDerivation(RequirementUsage derived, RequirementUsage original) { + var derivationMetadata = this.utilService.retrieveDerivationMetadata(derived); + var originalEndMetadata = this.utilService.retrieveOriginalRequirementMetadata(derived); + var derivedEndMetadata = this.utilService.retrieveDerivedRequirementMetadata(derived); + boolean metadataFound = derivationMetadata != null && originalEndMetadata != null && derivedEndMetadata != null; + + if (metadataFound && derived.getOwner() instanceof Namespace owningNamespace) { + var derivation = this.metamodelMutationElementService.createRequirementDerivation(derived, original, owningNamespace, derivationMetadata, originalEndMetadata, + derivedEndMetadata); + // The ends and the connection reference the metadata of the RequirementDerivation library, which has to be + // imported for the created derivation to be valid on its own. + this.importService.handleImport(derivation, derivationMetadata); + return derivation; + } + return derived; } /** diff --git a/backend/services/syson-model-services/src/main/java/org/eclipse/syson/model/services/aql/ModelMutationAQLService.java b/backend/services/syson-model-services/src/main/java/org/eclipse/syson/model/services/aql/ModelMutationAQLService.java index 9b5bb04844..f105b4cd9c 100644 --- a/backend/services/syson-model-services/src/main/java/org/eclipse/syson/model/services/aql/ModelMutationAQLService.java +++ b/backend/services/syson-model-services/src/main/java/org/eclipse/syson/model/services/aql/ModelMutationAQLService.java @@ -102,6 +102,13 @@ public Element createSatisfyRequirement(Element self, Element selectedObject) { return this.modelMutationElementService.createSatisfyRequirement(self, selectedObject); } + /** + * {@link ModelMutationElementService#createRequirementDerivation(RequirementUsage, RequirementUsage)}. + */ + public Element createRequirementDerivation(RequirementUsage derived, RequirementUsage original) { + return this.modelMutationElementService.createRequirementDerivation(derived, original); + } + /** * {@link MetamodelMutationElementService#createOccurrenceInOccurrence(Type, EClass)}. */ diff --git a/backend/services/syson-services/src/main/java/org/eclipse/syson/services/UtilService.java b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/UtilService.java index b00e89f27b..03cda9fe27 100644 --- a/backend/services/syson-services/src/main/java/org/eclipse/syson/services/UtilService.java +++ b/backend/services/syson-services/src/main/java/org/eclipse/syson/services/UtilService.java @@ -51,6 +51,7 @@ import org.eclipse.syson.sysml.InterfaceUsage; import org.eclipse.syson.sysml.LibraryPackage; import org.eclipse.syson.sysml.Membership; +import org.eclipse.syson.sysml.MetadataDefinition; import org.eclipse.syson.sysml.Namespace; import org.eclipse.syson.sysml.NamespaceImport; import org.eclipse.syson.sysml.ObjectiveMembership; @@ -655,6 +656,45 @@ public ActionUsage retrieveStandardDoneState(Element eObject) { return this.findByNameAndTypeInStandardLibraries(eObject, ActionUsage.class, "States::StateAction::done"); } + /** + * Retrieve the metadata definition marking a requirement derivation, defined inside the standard library + * RequirementDerivation. + * + * @param eObject + * an object to access to the library resources. + * + * @return the standard DerivationMetadata definition. + */ + public MetadataDefinition retrieveDerivationMetadata(Element eObject) { + return this.findByNameAndTypeInStandardLibraries(eObject, MetadataDefinition.class, "RequirementDerivation::DerivationMetadata"); + } + + /** + * Retrieve the metadata definition marking the original end of a requirement derivation, defined inside the + * standard library RequirementDerivation. + * + * @param eObject + * an object to access to the library resources. + * + * @return the standard OriginalRequirementMetadata definition. + */ + public MetadataDefinition retrieveOriginalRequirementMetadata(Element eObject) { + return this.findByNameAndTypeInStandardLibraries(eObject, MetadataDefinition.class, "RequirementDerivation::OriginalRequirementMetadata"); + } + + /** + * Retrieve the metadata definition marking the derived end of a requirement derivation, defined inside the standard + * library RequirementDerivation. + * + * @param eObject + * an object to access to the library resources. + * + * @return the standard DerivedRequirementMetadata definition. + */ + public MetadataDefinition retrieveDerivedRequirementMetadata(Element eObject) { + return this.findByNameAndTypeInStandardLibraries(eObject, MetadataDefinition.class, "RequirementDerivation::DerivedRequirementMetadata"); + } + private T findByNameAndTypeInStandardLibraries(Element context, Class klass, String qualifiedName) { return context.eResource().getResourceSet().getResources().stream() .flatMap(resource -> this.getLibraries(resource, true).stream()) diff --git a/backend/services/syson-sysml-metamodel-services/src/main/java/org/eclipse/syson/sysml/metamodel/services/MetamodelMutationElementService.java b/backend/services/syson-sysml-metamodel-services/src/main/java/org/eclipse/syson/sysml/metamodel/services/MetamodelMutationElementService.java index 54521e680a..3918ed73ae 100644 --- a/backend/services/syson-sysml-metamodel-services/src/main/java/org/eclipse/syson/sysml/metamodel/services/MetamodelMutationElementService.java +++ b/backend/services/syson-sysml-metamodel-services/src/main/java/org/eclipse/syson/sysml/metamodel/services/MetamodelMutationElementService.java @@ -26,14 +26,18 @@ import org.eclipse.syson.sysml.EndFeatureMembership; import org.eclipse.syson.sysml.Feature; import org.eclipse.syson.sysml.FeatureChaining; +import org.eclipse.syson.sysml.FeatureTyping; import org.eclipse.syson.sysml.Flow; import org.eclipse.syson.sysml.FlowEnd; import org.eclipse.syson.sysml.FlowUsage; import org.eclipse.syson.sysml.InterfaceUsage; import org.eclipse.syson.sysml.Membership; +import org.eclipse.syson.sysml.Metaclass; +import org.eclipse.syson.sysml.MetadataUsage; import org.eclipse.syson.sysml.Namespace; import org.eclipse.syson.sysml.OccurrenceDefinition; import org.eclipse.syson.sysml.OccurrenceUsage; +import org.eclipse.syson.sysml.OwningMembership; import org.eclipse.syson.sysml.Package; import org.eclipse.syson.sysml.PortUsage; import org.eclipse.syson.sysml.Redefinition; @@ -174,6 +178,82 @@ public ConnectionUsage createConnectionUsage(Feature source, Feature target, Ele return (ConnectionUsage) this.createConnector(source, target, sourceContainer, targetContainer, newConnectionContainer, SysmlFactory.eINSTANCE.createConnectionUsage()); } + /** + * Creates a new requirement derivation in the given container. + *

+ * SysML v2 has no dedicated metaclass for requirement derivation: it is a {@link ConnectionUsage} annotated with + * the {@code #derivation} metadata, whose ends are annotated with the {@code #original} and {@code #derive} + * metadata. Those three metadata definitions come from the {@code RequirementDerivation} standard library and are + * given as parameters, so that this service does not have to resolve them. + *

+ *

+ * The ends are annotated rather than relying on their declaration order, so that the direction of the derivation + * stays explicit. + *

+ * + * @param derived + * the requirement derived from {@code original} + * @param original + * the requirement {@code derived} is derived from + * @param newConnectionContainer + * the container of the new {@link ConnectionUsage} + * @param derivationMetadata + * the {@code DerivationMetadata} definition, applied on the connection + * @param originalEndMetadata + * the {@code OriginalRequirementMetadata} definition, applied on the end referencing {@code original} + * @param derivedEndMetadata + * the {@code DerivedRequirementMetadata} definition, applied on the end referencing {@code derived} + * @return the new requirement derivation + */ + public ConnectionUsage createRequirementDerivation(RequirementUsage derived, RequirementUsage original, Namespace newConnectionContainer, Metaclass derivationMetadata, + Metaclass originalEndMetadata, Metaclass derivedEndMetadata) { + // The original requirement is the first end, so that a derivation whose ends are not annotated, which is what + // the end order fallback of the display expects, is still oriented the same way. + ConnectionUsage derivation = this.createConnectionUsage(original, derived, original.getOwner(), derived.getOwner(), newConnectionContainer); + // A derivation is not referenced by its name, so the generated one is dropped, the way the generated name of a + // connector end is. It keeps the derivation anonymous, as it is when written in text. + derivation.setDeclaredName(null); + this.applyPrefixMetadata(derivation, derivationMetadata); + + List ends = derivation.getConnectorEnd(); + if (ends.size() == 2) { + this.applyPrefixMetadata(ends.get(0), originalEndMetadata); + this.applyPrefixMetadata(ends.get(1), derivedEndMetadata); + } + return derivation; + } + + /** + * Applies a prefix metadata, such as the {@code #derivation} of a requirement derivation, on the given element. + * + * @param annotatedElement + * the element to annotate + * @param metadataDefinition + * the definition of the applied metadata + * @return the new {@link MetadataUsage}, or {@code null} if no metadata definition was given + */ + public MetadataUsage applyPrefixMetadata(Element annotatedElement, Metaclass metadataDefinition) { + if (metadataDefinition == null) { + return null; + } + MetadataUsage metadataUsage = SysmlFactory.eINSTANCE.createMetadataUsage(); + OwningMembership owningMembership = SysmlFactory.eINSTANCE.createOwningMembership(); + owningMembership.getOwnedRelatedElement().add(metadataUsage); + annotatedElement.getOwnedRelationship().add(owningMembership); + + FeatureTyping featureTyping = SysmlFactory.eINSTANCE.createFeatureTyping(); + featureTyping.setType(metadataDefinition); + featureTyping.setTypedFeature(metadataUsage); + metadataUsage.getOwnedRelationship().add(featureTyping); + + this.elementInitializerSwitch.doSwitch(owningMembership); + this.elementInitializerSwitch.doSwitch(metadataUsage); + this.elementInitializerSwitch.doSwitch(featureTyping); + // A prefix metadata has no name of its own, it is identified by the definition it is typed by. + metadataUsage.setDeclaredName(null); + return metadataUsage; + } + /** * Creates a new {@link FlowUsage} in the given container. * diff --git a/backend/services/syson-sysml-metamodel-services/src/test/java/org/eclipse/syson/sysml/metamodel/services/MetamodelMutationElementServiceTest.java b/backend/services/syson-sysml-metamodel-services/src/test/java/org/eclipse/syson/sysml/metamodel/services/MetamodelMutationElementServiceTest.java new file mode 100644 index 0000000000..7a4deab6a4 --- /dev/null +++ b/backend/services/syson-sysml-metamodel-services/src/test/java/org/eclipse/syson/sysml/metamodel/services/MetamodelMutationElementServiceTest.java @@ -0,0 +1,131 @@ +/******************************************************************************* + * Copyright (c) 2026 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.syson.sysml.metamodel.services; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Objects; + +import org.eclipse.syson.sysml.ConnectionUsage; +import org.eclipse.syson.sysml.Element; +import org.eclipse.syson.sysml.Metaclass; +import org.eclipse.syson.sysml.MetadataDefinition; +import org.eclipse.syson.sysml.MetadataUsage; +import org.eclipse.syson.sysml.OwningMembership; +import org.eclipse.syson.sysml.Package; +import org.eclipse.syson.sysml.RequirementUsage; +import org.eclipse.syson.sysml.SysmlFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Test class for {@link MetamodelMutationElementService}. + * + * @author kabayama + */ +public class MetamodelMutationElementServiceTest { + + private static final String DERIVATION_METADATA = "DerivationMetadata"; + + private static final String ORIGINAL_METADATA = "OriginalRequirementMetadata"; + + private static final String DERIVED_METADATA = "DerivedRequirementMetadata"; + + private MetamodelMutationElementService mutationService; + + private MetamodelQueryElementService queryService; + + private Package owningPackage; + + private RequirementUsage original; + + private RequirementUsage derived; + + @BeforeEach + public void setUp() { + this.mutationService = new MetamodelMutationElementService(); + this.queryService = new MetamodelQueryElementService(); + + this.owningPackage = SysmlFactory.eINSTANCE.createPackage(); + this.owningPackage.setDeclaredName("ReqTrace"); + this.original = this.createRequirement("article5Compliance"); + this.derived = this.createRequirement("paymentDelayPrevention"); + } + + @DisplayName("GIVEN two requirements, WHEN creating a requirement derivation between them, THEN the derivation is recognized as one and its ends are resolved") + @Test + public void createRequirementDerivation() { + ConnectionUsage derivation = this.mutationService.createRequirementDerivation(this.derived, this.original, this.owningPackage, + this.createMetadataDefinition(DERIVATION_METADATA), + this.createMetadataDefinition(ORIGINAL_METADATA), + this.createMetadataDefinition(DERIVED_METADATA)); + + assertThat(derivation).isNotNull(); + assertThat(derivation.getOwner()).isSameAs(this.owningPackage); + assertThat(this.appliedMetadataNamesOf(derivation)).containsExactly(DERIVATION_METADATA); + assertThat(derivation.getConnectorEnd()).hasSize(2); + // A derivation is anonymous, the way it is when written in text. + assertThat(derivation.getDeclaredName()).isNull(); + } + + @DisplayName("GIVEN a created requirement derivation, WHEN reading its ends, THEN the original and the derived requirements are the ones given at creation") + @Test + public void createdRequirementDerivationHasItsEndsAnnotated() { + ConnectionUsage derivation = this.mutationService.createRequirementDerivation(this.derived, this.original, this.owningPackage, + this.createMetadataDefinition(DERIVATION_METADATA), + this.createMetadataDefinition(ORIGINAL_METADATA), + this.createMetadataDefinition(DERIVED_METADATA)); + + assertThat(this.appliedMetadataNamesOf(derivation.getConnectorEnd().get(0))).containsExactly(ORIGINAL_METADATA); + assertThat(this.appliedMetadataNamesOf(derivation.getConnectorEnd().get(1))).containsExactly(DERIVED_METADATA); + assertThat(this.queryService.getDerivationOriginalEnd(derivation)).isSameAs(this.original); + assertThat(this.queryService.getDerivationDerivedEnd(derivation)).isSameAs(this.derived); + } + + @DisplayName("GIVEN no metadata definition, WHEN applying a prefix metadata, THEN nothing is applied") + @Test + public void applyPrefixMetadataWithoutDefinition() { + assertThat(this.mutationService.applyPrefixMetadata(this.original, null)).isNull(); + assertThat(this.appliedMetadataNamesOf(this.original)).isEmpty(); + } + + private RequirementUsage createRequirement(String name) { + RequirementUsage requirement = SysmlFactory.eINSTANCE.createRequirementUsage(); + requirement.setDeclaredName(name); + OwningMembership membership = SysmlFactory.eINSTANCE.createOwningMembership(); + membership.getOwnedRelatedElement().add(requirement); + this.owningPackage.getOwnedRelationship().add(membership); + return requirement; + } + + private MetadataDefinition createMetadataDefinition(String name) { + MetadataDefinition metadataDefinition = SysmlFactory.eINSTANCE.createMetadataDefinition(); + metadataDefinition.setDeclaredName(name); + return metadataDefinition; + } + + private List appliedMetadataNamesOf(Element element) { + return element.getOwnedRelationship().stream() + .filter(OwningMembership.class::isInstance) + .map(OwningMembership.class::cast) + .flatMap(membership -> membership.getOwnedRelatedElement().stream()) + .filter(MetadataUsage.class::isInstance) + .map(MetadataUsage.class::cast) + .map(MetadataUsage::getMetadataDefinition) + .filter(Objects::nonNull) + .map(Metaclass::getDeclaredName) + .toList(); + } +} diff --git a/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/services/ViewEdgeToolService.java b/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/services/ViewEdgeToolService.java index 33defbde87..14b49f9ab9 100644 --- a/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/services/ViewEdgeToolService.java +++ b/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/services/ViewEdgeToolService.java @@ -664,6 +664,34 @@ public EdgeTool createSatisfyRequirementEdgeTool() { .build(); } + /** + * Creates the edge tool creating a requirement derivation between two {@link org.eclipse.syson.sysml.RequirementUsage}. + *

+ * The edge is drawn from the derived requirement to the original one, the direction the derivation is read in and + * the direction its edge is displayed in. + *

+ * + * @return the edge tool creating a requirement derivation + */ + public EdgeTool createRequirementDerivationEdgeTool() { + var builder = this.diagramBuilderHelper.newEdgeTool(); + var body = this.viewBuilderHelper.newChangeContext() + .expression(ServiceMethod.of1(ModelMutationAQLService::createRequirementDerivation).aql(EdgeDescription.SEMANTIC_EDGE_SOURCE, EdgeDescription.SEMANTIC_EDGE_TARGET)); + + var targetNodeDescriptions = new ArrayList(); + var requirementUsageNodeDescriptionName = this.nameGenerator.getNodeName(SysmlPackage.eINSTANCE.getRequirementUsage()); + this.allNodeDescriptions.stream() + .filter(nd -> requirementUsageNodeDescriptionName.equals(nd.getName())) + .findFirst() + .ifPresent(targetNodeDescriptions::add); + + return builder.name(this.nameGenerator.getCreationToolName("New Derived ", SysmlPackage.eINSTANCE.getRequirementUsage())) + .iconURLsExpression(METAMODEL_ICONS_PATH + SysmlPackage.eINSTANCE.getConnectionUsage().getName() + SVG) + .body(body.build()) + .targetElementDescriptions(targetNodeDescriptions.toArray(NodeDescription[]::new)) + .build(); + } + public EdgeTool createFramedConcernEdgeTool() { var builder = this.diagramBuilderHelper.newEdgeTool(); var body = this.viewBuilderHelper.newChangeContext() diff --git a/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/services/ViewEdgeToolSwitch.java b/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/services/ViewEdgeToolSwitch.java index a6e811e6b5..58f9c017ab 100644 --- a/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/services/ViewEdgeToolSwitch.java +++ b/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/services/ViewEdgeToolSwitch.java @@ -290,6 +290,7 @@ public List caseRequirementUsage(RequirementUsage object) { */ private List createRequirementUsageEdgeTools(EClass eClass, RequirementUsage requirementUsage) { var edgeTools = new ArrayList(); + edgeTools.add(this.edgeToolService.createRequirementDerivationEdgeTool()); var targetNodes = this.allNodeDescriptions.stream().filter(nodeDesc -> nodeDesc.getName().toLowerCase().endsWith(USAGE) || this.edgeToolService.isTheNodeDescriptionFor(nodeDesc, SysmlPackage.eINSTANCE.getRequirementDefinition())).collect(Collectors.toList()); targetNodes.removeIf(nodeDesc -> this.edgeToolService.isTheNodeDescriptionFor(nodeDesc, SysmlPackage.eINSTANCE.getPortUsage())); diff --git a/doc/content/modules/user-manual/assets/images/release-notes-new-derived-requirement-tool.png b/doc/content/modules/user-manual/assets/images/release-notes-new-derived-requirement-tool.png new file mode 100644 index 0000000000..75bf636c49 Binary files /dev/null and b/doc/content/modules/user-manual/assets/images/release-notes-new-derived-requirement-tool.png differ diff --git a/doc/content/modules/user-manual/pages/release-notes/2026.9.0.adoc b/doc/content/modules/user-manual/pages/release-notes/2026.9.0.adoc index 8d7d4b3bac..4dff55e80c 100644 --- a/doc/content/modules/user-manual/pages/release-notes/2026.9.0.adoc +++ b/doc/content/modules/user-manual/pages/release-notes/2026.9.0.adoc @@ -31,7 +31,6 @@ The subject inheritance in _subject_ compartment is also visible in the followin *** `ConcernUsage` *** `SatisfyRequirementUsage` ** Add support for the inheritance of the parameter border nodes of `ActionDefinition` and `ActionUsage` graphical nodes. - ** Add _requirements_ compartments to `PartUsage` and `PartDefinition` graphical nodes. + image::release-notes-requirements-compartment-parts.png[Requirements compartments in PartUsage and PartDefinition graphical nodes, width=60%,height=60%] @@ -80,6 +79,15 @@ image::release-notes-action-inherited-item-border-nodes.png[ItemUsage border nod + image::release-notes-port-inherited-item-border-nodes.png[ItemUsage border node inheritance on PortDefinition and PortUsage] +** Requirement derivations can now be created from a _General View_ diagram, with the _New Derived Requirement_ edge tool of `RequirementUsage` graphical nodes. ++ +The edge is drawn from the derived requirement to the original one, the direction the derivation is read in: _this requirement is derived from that one_. ++ +image::release-notes-new-derived-requirement-tool.png[New Derived Requirement edge tool on RequirementUsage graphical nodes, width=60%,height=60%] ++ +A derivation created this way is the same as one written in text, so both can be mixed in the same model, and exporting the model gives back the `#derivation` connection shown above. +The `RequirementDerivation` library is imported automatically when the model does not import it yet. + == Bug fixes * In diagrams: