diff --git a/com.avaloq.tools.ddk.xtext.export.test/META-INF/MANIFEST.MF b/com.avaloq.tools.ddk.xtext.export.test/META-INF/MANIFEST.MF index 9348654567..abe3660b6c 100644 --- a/com.avaloq.tools.ddk.xtext.export.test/META-INF/MANIFEST.MF +++ b/com.avaloq.tools.ddk.xtext.export.test/META-INF/MANIFEST.MF @@ -10,10 +10,9 @@ Fragment-Host: com.avaloq.tools.ddk.xtext.export.ui Require-Bundle: com.avaloq.tools.ddk.xtext.export, com.google.inject, com.avaloq.tools.ddk.xtext.test.core, - org.junit, com.avaloq.tools.ddk.xtext, junit-jupiter-api, junit-jupiter-engine, - junit-vintage-engine + junit-platform-suite-api Export-Package: com.avaloq.tools.ddk.xtext.test.export Automatic-Module-Name: com.avaloq.tools.ddk.xtext.export.test diff --git a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/exporting/ExportExportingTest.java b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/exporting/ExportExportingTest.java index b76a3504e1..a128765d2e 100644 --- a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/exporting/ExportExportingTest.java +++ b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/exporting/ExportExportingTest.java @@ -10,10 +10,10 @@ *******************************************************************************/ package com.avaloq.tools.ddk.xtext.export.exporting; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import com.avaloq.tools.ddk.xtext.test.AbstractXtextTest; import com.avaloq.tools.ddk.xtext.test.export.util.ExportTestUtil; +import com.avaloq.tools.ddk.xtext.test.jupiter.AbstractXtextTest; /** diff --git a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/formatting/ExportFormattingTest.java b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/formatting/ExportFormattingTest.java index f88ce7b667..431855c9c8 100644 --- a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/formatting/ExportFormattingTest.java +++ b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/formatting/ExportFormattingTest.java @@ -12,7 +12,7 @@ import com.avaloq.tools.ddk.xtext.test.TestSource; import com.avaloq.tools.ddk.xtext.test.export.util.ExportTestUtil; -import com.avaloq.tools.ddk.xtext.test.formatting.AbstractFormattingTest; +import com.avaloq.tools.ddk.xtext.test.jupiter.AbstractFormattingTest; /** diff --git a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/scoping/ExportScopingTest.java b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/scoping/ExportScopingTest.java index c691ccb852..67f16199e7 100644 --- a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/scoping/ExportScopingTest.java +++ b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/scoping/ExportScopingTest.java @@ -10,20 +10,20 @@ *******************************************************************************/ package com.avaloq.tools.ddk.xtext.export.scoping; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import java.io.IOException; import org.eclipse.xtext.naming.QualifiedName; import org.eclipse.xtext.scoping.IScope; -import org.junit.Test; +import org.junit.jupiter.api.Test; import com.avaloq.tools.ddk.xtext.export.export.ExportModel; import com.avaloq.tools.ddk.xtext.export.export.ExportPackage; import com.avaloq.tools.ddk.xtext.scoping.IDomain.NullMapper; import com.avaloq.tools.ddk.xtext.test.export.util.ExportTestUtil; -import com.avaloq.tools.ddk.xtext.test.scoping.AbstractScopingTest; +import com.avaloq.tools.ddk.xtext.test.jupiter.AbstractScopingTest; /** @@ -46,16 +46,16 @@ protected ExportTestUtil getXtextTestUtil() { public void testImportPackageScope() throws IOException { ExportModel model = (ExportModel) getTestSource().getModel(); IScope scope = scopeProvider.scope_Import_package(model.getImports().get(0), ExportPackage.Literals.IMPORT__PACKAGE); - assertNotNull("Could not locate Import.", scope.getSingleElement(QualifiedName.create("http://www.avaloq.com/tools/ddk/xtext/export/Export"))); - assertNull("Located non-existent Import.", scope.getSingleElement(QualifiedName.create("http://www.avaloq.com/tools/ddk/xtext/export/ExportX"))); + assertNotNull(scope.getSingleElement(QualifiedName.create("http://www.avaloq.com/tools/ddk/xtext/export/Export")), "Could not locate Import."); + assertNull(scope.getSingleElement(QualifiedName.create("http://www.avaloq.com/tools/ddk/xtext/export/ExportX")), "Located non-existent Import."); } @Test public void testEclassScope() throws IOException { ExportModel model = (ExportModel) getTestSource().getModel(); IScope scope = scopeProvider.scope_EClass(model, null); - assertNotNull("Could not locate EClass.", scope.getSingleElement(QualifiedName.create("InterfaceExpression"))); - assertNull("Located non-existent EClass.", scope.getSingleElement(QualifiedName.create("InterfaceExpressionX"))); + assertNotNull(scope.getSingleElement(QualifiedName.create("InterfaceExpression")), "Could not locate EClass."); + assertNull(scope.getSingleElement(QualifiedName.create("InterfaceExpressionX")), "Located non-existent EClass."); } @Test @@ -63,8 +63,8 @@ public void testEStructuralFeatureScope() throws IOException { ExportModel model = (ExportModel) getTestSource().getModel(); IScope scope = scopeProvider.scope_EStructuralFeature(model.getInterfaces().get(0), null); // CHECKSTYLE:OFF (DuplicateString) - assertNotNull("Could not locate EStructuralFeature.", scope.getSingleElement(QualifiedName.create("unordered"))); - assertNull("Located non-existent EStructuralFeature.", scope.getSingleElement(QualifiedName.create("unorderedX"))); + assertNotNull(scope.getSingleElement(QualifiedName.create("unordered")), "Could not locate EStructuralFeature."); + assertNull(scope.getSingleElement(QualifiedName.create("unorderedX")), "Located non-existent EStructuralFeature."); // CHECKSTYLE:ON } @@ -73,8 +73,8 @@ public void testEAttributeScope() throws IOException { ExportModel model = (ExportModel) getTestSource().getModel(); IScope scope = scopeProvider.scope_EAttribute(model.getInterfaces().get(0), null); // CHECKSTYLE:OFF (DuplicateString) - assertNotNull("Could not locate EStructuralFeature.", scope.getSingleElement(QualifiedName.create("unordered"))); - assertNull("Located non-existent EStructuralFeature.", scope.getSingleElement(QualifiedName.create("expr"))); + assertNotNull(scope.getSingleElement(QualifiedName.create("unordered")), "Could not locate EStructuralFeature."); + assertNull(scope.getSingleElement(QualifiedName.create("expr")), "Located non-existent EStructuralFeature."); // CHECKSTYLE:ON } @@ -83,8 +83,8 @@ public void testInterfaceNavigationRefScope() throws IOException { ExportModel model = (ExportModel) getTestSource().getModel(); IScope scope = scopeProvider.scope_InterfaceNavigation_ref(model.getInterfaces().get(0), null); // CHECKSTYLE:OFF (DuplicateString) - assertNotNull("Could not locate InterfaceNavigationRef.", scope.getSingleElement(QualifiedName.create("expr"))); - assertNull("Located non-existent InterfaceNavigationRef.", scope.getSingleElement(QualifiedName.create("unordered"))); + assertNotNull(scope.getSingleElement(QualifiedName.create("expr")), "Could not locate InterfaceNavigationRef."); + assertNull(scope.getSingleElement(QualifiedName.create("unordered")), "Located non-existent InterfaceNavigationRef."); // CHECKSTYLE:ON } diff --git a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/validation/ExportValidationOkTest.java b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/validation/ExportValidationOkTest.java index 68a802d179..ca1b97faa7 100644 --- a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/validation/ExportValidationOkTest.java +++ b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/validation/ExportValidationOkTest.java @@ -10,10 +10,10 @@ *******************************************************************************/ package com.avaloq.tools.ddk.xtext.export.validation; -import org.junit.Test; +import org.junit.jupiter.api.Test; import com.avaloq.tools.ddk.xtext.test.export.util.ExportTestUtil; -import com.avaloq.tools.ddk.xtext.test.validation.AbstractValidationTest; +import com.avaloq.tools.ddk.xtext.test.jupiter.AbstractValidationTest; /** diff --git a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/validation/ExportValidationTest.java b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/validation/ExportValidationTest.java index 7d0dc1c518..3eaa8fac2c 100644 --- a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/validation/ExportValidationTest.java +++ b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/validation/ExportValidationTest.java @@ -10,10 +10,10 @@ *******************************************************************************/ package com.avaloq.tools.ddk.xtext.export.validation; -import org.junit.Test; +import org.junit.jupiter.api.Test; import com.avaloq.tools.ddk.xtext.test.export.util.ExportTestUtil; -import com.avaloq.tools.ddk.xtext.test.validation.AbstractValidationTest; +import com.avaloq.tools.ddk.xtext.test.jupiter.AbstractValidationTest; /** diff --git a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/ExportTestSuite.java b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/ExportTestSuite.java index 44206dc478..1eee326800 100644 --- a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/ExportTestSuite.java +++ b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/ExportTestSuite.java @@ -10,8 +10,8 @@ *******************************************************************************/ package com.avaloq.tools.ddk.xtext.test.export; -import org.junit.runner.RunWith; -import org.junit.runners.Suite; +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; import com.avaloq.tools.ddk.xtext.export.exporting.ExportExportingTest; import com.avaloq.tools.ddk.xtext.export.formatting.ExportFormattingTest; @@ -22,7 +22,7 @@ /** * Empty class serving only as holder for JUnit4 annotations. */ -@RunWith(Suite.class) -@Suite.SuiteClasses({ExportFormattingTest.class, ExportValidationTest.class, ExportScopingTest.class, ExportExportingTest.class}) +@Suite +@SelectClasses({ExportFormattingTest.class, ExportValidationTest.class, ExportScopingTest.class, ExportExportingTest.class}) public class ExportTestSuite { } diff --git a/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractFormattingTest.java b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractFormattingTest.java new file mode 100644 index 0000000000..1763755138 --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractFormattingTest.java @@ -0,0 +1,172 @@ +/******************************************************************************* + * Copyright (c) 2025 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.xtext.test.jupiter; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.eclipse.emf.ecore.EObject; +import org.eclipse.xtext.formatting.INodeModelFormatter; +import org.eclipse.xtext.formatting.INodeModelFormatter.IFormattedRegion; +import org.eclipse.xtext.nodemodel.ICompositeNode; +import org.eclipse.xtext.nodemodel.util.NodeModelUtils; +import org.eclipse.xtext.resource.SaveOptions; +import org.junit.jupiter.api.Test; + + +/** + * Base class for formatting tests. + */ +@SuppressWarnings("nls") +public abstract class AbstractFormattingTest extends AbstractXtextTest { + + private static final String CR_LF = "\r\n"; + private static final String LF = "\n"; + + @Override + protected List getRequiredSourceFileNames() { + List result = super.getRequiredSourceFileNames(); + result.add(getExpectedTestSourceFileName()); + return result; + } + + @Override + protected String getTestSourceModelName() { + return super.getTestSourceModelName() + "Input"; + } + + /** + * The default implementation returns the default source model name for the test class and adds 'Expected'. + * this test. A test class needs to override this, if the name of the expected formatting test source model differs from the default. + * + * @return the name of the expected formatting test source model + */ + private String getExpectedTestSourceModelName() { + return super.getTestSourceModelName() + "Expected"; + } + + /** + * The default implementation returns the default source model name for the test class and adds 'Expected' and the default file extension for the grammar of + * this test. A test class needs to override this, if the name of the expected formatting test source file differs from the default. + * + * @return the name of the expected formatting test source file + */ + protected String getExpectedTestSourceFileName() { + return getExpectedTestSourceModelName() + "." + getXtextTestUtil().getFileExtension(); + } + + /** + * Test formatting based on the NodeModel. + */ + @Test + public void formattedNodeModel() { + assertFormattedNodeModel(); + } + + /** + * Test formatting based on the ParseTreeConstructor. + */ + public void formattedParseTreeConstructor() { + assertFormattedParseTreeConstructor(); + } + + /** + * Test preservation of formatting using ParseTreeConstructor. + */ + public void preservedParseTreeConstructor() { + assertPreservedParseTreeConstructor(); + } + + /** + * Test preservation of formatting using NodeModelFormatter. + */ + @Test + public void preservedNodeModel() { + assertPreservedNodeModel(); + } + + /** + * Test formatting based on the ParseTreeConstructor. + */ + protected final void assertFormattedParseTreeConstructor() { + assertFormattedParseTreeConstructor(getSemanticModel(), getTestSource(getExpectedTestSourceFileName()).getContent()); + } + + /** + * Test formatting based on the NodeModel. + * + * @param offset + * Offset from which to start formatting + * @param length + * Length of region to format + */ + private void assertFormattedNodeModel(final int offset, final int length) { + assertFormattedNodeModel(getSemanticModel(), getTestSource().getContent(), getTestSource(getExpectedTestSourceFileName()).getContent(), offset, length); + } + + /** + * Test formatting based on the NodeModel. + */ + private void assertFormattedNodeModel() { + assertFormattedNodeModel(0, getTestSource().getContent().length()); + } + + /** + * Test preservation of formatting. + */ + private void assertPreservedNodeModel() { + String expectedContent = getTestSource(getExpectedTestSourceFileName()).getContent(); + assertFormattedNodeModel(getTestSource(getExpectedTestSourceFileName()).getModel(), expectedContent, expectedContent, 0, expectedContent.length()); + } + + /** + * Test preservation of formatting. + */ + protected final void assertPreservedParseTreeConstructor() { + assertFormattedParseTreeConstructor(getTestSource(getExpectedTestSourceFileName()).getModel(), getTestSource(getExpectedTestSourceFileName()).getContent()); + } + + /** + * Test formatting based on the ParseTreeConstructor. + * + * @param model + * the model to be serialized and compared with expected string + * @param expected + * Expected formatted String + */ + private void assertFormattedParseTreeConstructor(final EObject model, final String expected) { + String actual = getXtextTestUtil().getSerializer().serialize(model, SaveOptions.newBuilder().format().getOptions()); + assertEquals(expected.replaceAll(CR_LF, LF), actual.replaceAll(CR_LF, LF), "Formatted ParseTree"); + } + + /** + * Test formatting based on the NodeModel. + * + * @param model + * the model to check + * @param input + * String representing a serialized model + * @param expected + * Expected formatted String + * @param offset + * Offset from which to start formatting + * @param length + * Length of region to format + */ + private void assertFormattedNodeModel(final EObject model, final String input, final String expected, final int offset, final int length) { + ICompositeNode node = NodeModelUtils.getNode(model).getRootNode(); + IFormattedRegion region = getXtextTestUtil().get(INodeModelFormatter.class).format(node, offset, length); + String actual = input.substring(0, offset) + region.getFormattedText() + input.substring(length + offset); + assertEquals(expected.replaceAll(CR_LF, LF), actual.replaceAll(CR_LF, LF), "Formatted NodeModel"); + } + +} diff --git a/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractScopingTest.java b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractScopingTest.java new file mode 100644 index 0000000000..4d3d271fb9 --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractScopingTest.java @@ -0,0 +1,874 @@ +/******************************************************************************* + * Copyright (c) 2025 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.xtext.test.jupiter; + +import static com.avaloq.tools.ddk.xtext.linking.AbstractFragmentProvider.REP_SEPARATOR; +import static com.avaloq.tools.ddk.xtext.linking.AbstractFragmentProvider.SEGMENT_SEPARATOR; +import static com.avaloq.tools.ddk.xtext.resource.AbstractSelectorFragmentProvider.EQ_OP; +import static com.avaloq.tools.ddk.xtext.resource.AbstractSelectorFragmentProvider.SELECTOR_END; +import static com.avaloq.tools.ddk.xtext.resource.AbstractSelectorFragmentProvider.SELECTOR_START; +import static com.avaloq.tools.ddk.xtext.resource.AbstractSelectorFragmentProvider.UNIQUE; +import static com.avaloq.tools.ddk.xtext.resource.AbstractSelectorFragmentProvider.VALUE_SEP; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +import org.eclipse.core.runtime.Assert; +import org.eclipse.emf.common.util.EList; +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.EClass; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.EReference; +import org.eclipse.emf.ecore.util.EObjectResolvingEList; +import org.eclipse.emf.ecore.util.EcoreUtil; +import org.eclipse.osgi.util.NLS; +import org.eclipse.xtext.Assignment; +import org.eclipse.xtext.CrossReference; +import org.eclipse.xtext.EcoreUtil2; +import org.eclipse.xtext.naming.QualifiedName; +import org.eclipse.xtext.nodemodel.INode; +import org.eclipse.xtext.nodemodel.util.NodeModelUtils; +import org.eclipse.xtext.resource.IEObjectDescription; +import org.eclipse.xtext.resource.IResourceDescription; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.eclipse.xtext.resource.XtextResource; +import org.eclipse.xtext.scoping.IScope; +import org.eclipse.xtext.scoping.IScopeProvider; +import org.eclipse.xtext.util.Triple; +import org.eclipse.xtext.xbase.lib.Pair; + +import com.avaloq.tools.ddk.caching.Regexps; +import com.avaloq.tools.ddk.xtext.linking.AbstractFragmentProvider; +import com.avaloq.tools.ddk.xtext.naming.QualifiedNames; +import com.avaloq.tools.ddk.xtext.resource.IFingerprintComputer; +import com.avaloq.tools.ddk.xtext.scoping.ContainerQuery; +import com.avaloq.tools.ddk.xtext.scoping.IDomain; +import com.google.common.base.Function; +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; + + +/** + * Base class for scoping tests. + */ +@SuppressWarnings("nls") +public abstract class AbstractScopingTest extends AbstractXtextMarkerBasedTest { + private static final String PARAMETER_EXPECTED_OBJECTS = "expectedObjects"; + private static final String PARAMETER_REFERENCE = "reference"; + private static final String PARAMETER_CONTEXT = "context"; + public static final String TOP_LEVEL_OBJECT_FRAGMENT = SEGMENT_SEPARATOR + "0" + SEGMENT_SEPARATOR + "1"; + public static final String TOP_LEVEL_SURROGATE_FRAGMENT = SEGMENT_SEPARATOR + "0" + REP_SEPARATOR + "2"; + public static final String INFERRED_DATA_DICTIONARY_FRAGMENT = SEGMENT_SEPARATOR + "1"; + + private static final String NUMBER_OF_ELEMENTS_MESSAGE = "Incorrect number of elements in scope."; + private static final Splitter FRAGMENT_SEGMENT_SPLITTER = Splitter.onPattern("(? expectedLinkAssertions = new ArrayList(); + + /** + * Creates a new instance of {@link AbstractScopingTest}. + */ + public AbstractScopingTest() { + this(new IDomain.NullMapper()); + + } + + /** + * Creates a new instance of {@link AbstractScopingTest}. + * + * @param domainMapper + * the domainMapper to use + */ + public AbstractScopingTest(final IDomain.Mapper domainMapper) { + this.domainMapper = domainMapper; + } + + /** + * Returns all contents of the main {@link XtextTestResource}. + * + * @return all contents of the main {@link XtextTestResource} + */ + @SuppressWarnings("unchecked") + public Iterable getContents() { + return (Iterable) getTestInformation().getTestObject(Iterable.class); + } + + /** + * Set up scoping. + */ + @Override + protected void beforeAllTests() { + super.beforeAllTests(); + Iterable allContents = new Iterable() { + @Override + public Iterator iterator() { + return getXtextTestResource().getAllContents(); + } + }; + getTestInformation().putTestObject(Iterable.class, allContents); + } + + @Override + protected void afterEachTest() { + assertTrue(expectedLinkAssertions.isEmpty(), "Expected links were set with link(int) but testLinking(String, CharSequence) was never called"); + super.afterEachTest(); + } + + /** + * Returns the scope provider used for unit testing. + * + * @return the scope provider instance + */ + protected IScopeProvider getScopeProvider() { + return getXtextTestUtil().get(IScopeProvider.class); + } + + /** + * Check if scope expected is found in context provided. + * + * @param context + * element from which an element shall be referenced + * @param reference + * to be used to filter the elements + * @param expectedSourceName + * (upper case!) name of scope element to look for (kernel source) + * @param expectedSourceType + * type of scope element to look for + */ + protected void assertScope(final EObject context, final EReference reference, final String expectedSourceName, final String expectedSourceType) { + assertScope(context, reference, QualifiedNames.safeQualifiedName(expectedSourceName), getTargetSourceUri(expectedSourceName + '.' + + expectedSourceType).appendFragment(TOP_LEVEL_OBJECT_FRAGMENT)); + } + + /** + * Check if scope expected is found in context provided. + * + * @param context + * element from which an element shall be referenced + * @param reference + * to be used to filter the elements + * @param expectedElementName + * name of scope element to look for + * @param expectedSourceName + * (upper case!) name of the source within to look for the scope element + * @param expectedSourceType + * type of scope element to look for + * @param referenceElementType + * the type of the referenced element + */ + @SuppressWarnings("PMD.UseObjectForClearerAPI") + protected void assertScopeForElement(final EObject context, final EReference reference, final String expectedElementName, final String expectedSourceName, final String expectedSourceType, final String referenceElementType) { + assertScope(context, reference, QualifiedNames.safeQualifiedName(expectedElementName), getTargetSourceUri(expectedSourceName + '.' + + expectedSourceType).appendFragment(TOP_LEVEL_OBJECT_FRAGMENT + SEGMENT_SEPARATOR + referenceElementType)); + } + + /** + * Check if scope expected is found in context provided. + * + * @param context + * element from which an element shall be referenced + * @param reference + * to be used to filter the elements + * @param expectedUris + * of source referenced + */ + protected void assertScope(final EObject context, final EReference reference, final URI... expectedUris) { + assertScope(context, reference, Sets.newHashSet(expectedUris)); + } + + /** + * Check if scope expected is found in context provided. + * + * @param context + * element from which an element shall be referenced + * @param reference + * to be used to filter the elements + * @param expectedUriSet + * of source referenced + */ + protected void assertScope(final EObject context, final EReference reference, final Set expectedUriSet) { + IScope scope = getScopeProvider().getScope(context, reference); + for (IEObjectDescription description : scope.getAllElements()) { + expectedUriSet.remove(description.getEObjectURI()); + if (expectedUriSet.isEmpty()) { + return; + } + } + assertTrue(expectedUriSet.isEmpty(), "Expected URIs not found in scope: " + expectedUriSet); + } + + /** + * Checks if the given objects are in scope of the given reference for the given context. + * + * @param context + * {@link EObject} element from which an element shall be referenced, must not be {@code null} + * @param reference + * the structural feature of {@code context} for which the scope should be asserted, must not be {@code null} and part of the context element + * @param expectedObjects + * for given scope, must not be {@code null} + */ + protected void assertScopedObjects(final EObject context, final EReference reference, final EObject... expectedObjects) { + Assert.isNotNull(expectedObjects, PARAMETER_EXPECTED_OBJECTS); + assertScopedObjects(context, reference, Lists.newArrayList(expectedObjects)); + } + + /** + * Checks if the given objects are in scope of the given reference for the given context. + * + * @param context + * {@link EObject} element from which an element shall be referenced, must not be {@code null} + * @param reference + * the structural feature of {@code context} for which the scope should be asserted, must not be {@code null} and part of the context element + * @param firstExpectedObjectCollection + * for given scope, must not be {@code null} + * @param furtherExpectedObjectCollections + * for given scope, must not be {@code null} + */ + @SuppressWarnings("unchecked") + protected void assertScopedObjects(final EObject context, final EReference reference, final Collection firstExpectedObjectCollection, final Collection... furtherExpectedObjectCollections) { + Assert.isNotNull(firstExpectedObjectCollection, "firstExpectedObjectCollection"); + Assert.isNotNull(furtherExpectedObjectCollections, "furtherExpectedObjectCollections"); + Collection consolidatedList = Lists.newArrayList(firstExpectedObjectCollection); + for (Collection expectedObjects : furtherExpectedObjectCollections) { + consolidatedList.addAll(expectedObjects); + } + assertScopedObjects(context, reference, consolidatedList); + } + + /** + * Checks if the scope of the given reference for the given context contains only the expected objects. + * In addition, checks that the reference of the given context references at least one of the expected + * objects. If the reference has multiplicity > 1, then every reference must reference at least + * one of the expected objects. + * + * @param context + * {@link EObject} from which the given objects shall be referenced, must not be {@code null} + * @param reference + * the structural feature of {@code context} for which the scope should be asserted, must not be {@code null} and part of the context element + * @param expectedObjects + * the objects expected in the scope, must not be {@code null} + */ + protected void assertScopedObjects(final EObject context, final EReference reference, final Collection expectedObjects) { + Assert.isNotNull(context, PARAMETER_CONTEXT); + Assert.isNotNull(reference, PARAMETER_REFERENCE); + Assert.isNotNull(expectedObjects, PARAMETER_EXPECTED_OBJECTS); + Assert.isTrue(context.eClass().getEAllReferences().contains(reference), String.format("Contract for argument '%s' failed: Parameter is not within specified range (Expected: %s, Actual: %s).", PARAMETER_CONTEXT, "The context object must contain the given reference.", "Reference not contained by the context object!")); + Set expectedUriSet = Sets.newHashSet(); + for (EObject object : expectedObjects) { + expectedUriSet.add(EcoreUtil.getURI(object)); + } + IScope scope = getScopeProvider().getScope(context, reference); + Iterable allScopedElements = scope.getAllElements(); + Set scopedUriSet = Sets.newHashSet(); + for (IEObjectDescription description : allScopedElements) { + URI uri = description.getEObjectURI(); + scopedUriSet.add(uri); + } + if (!expectedUriSet.equals(scopedUriSet)) { + fail("The scope must exactly consist of the expected URIs. Missing " + Sets.difference(expectedUriSet, scopedUriSet) + " extra " + + Sets.difference(scopedUriSet, expectedUriSet)); + } + // test that link resolving worked + boolean elementResolved; + if (reference.isMany()) { + @SuppressWarnings("unchecked") + EList objects = (EList) context.eGet(reference, true); + elementResolved = !objects.isEmpty(); // NOPMD + for (Iterator objectIter = objects.iterator(); objectIter.hasNext() && elementResolved;) { + EObject eObject = EcoreUtil.resolve(objectIter.next(), context); + elementResolved = expectedUriSet.contains(EcoreUtil.getURI(eObject)); + } + } else { + EObject resolvedObject = (EObject) context.eGet(reference, true); + elementResolved = expectedUriSet.contains(EcoreUtil.getURI(resolvedObject)); + } + assertTrue(elementResolved, "Linking must have resolved one of the expected objects."); + } + + /** + * Check if scope expected is found in context provided. + * + * @param context + * element from which an element shall be referenced + * @param reference + * to be used to filter the elements + * @param expectedName + * name of scope element to look for + * @param expectedUri + * of source referenced + */ + private void assertScope(final EObject context, final EReference reference, final QualifiedName expectedName, final URI expectedUri) { + IScope scope = getScopeProvider().getScope(context, reference); + Iterable descriptions = scope.getElements(expectedName); + assertFalse(Iterables.isEmpty(descriptions), "Description missing for: " + expectedName); + URI currentUri = null; + for (IEObjectDescription desc : descriptions) { + currentUri = desc.getEObjectURI(); + if (currentUri.equals(expectedUri)) { + return; + } + } + assertEquals(expectedUri, currentUri, "Scope URI is not equal to expected URI"); + } + + /** + * Assert the scope for given elements. + * + * @param context + * the context + * @param reference + * the reference + * @param expectedSourceName + * the name of the referenced source (without file extension) + * @param expectedSourceType + * type of scope element to look for + * @param elementNames + * array of tuples with the name and uri of each element + */ + protected void assertScopeForElements(final EObject context, final EReference reference, final String expectedSourceName, final String expectedSourceType, final String[]... elementNames) { + for (String[] elementName : elementNames) { + assertScopeForElement(context, reference, elementName[0], expectedSourceName, expectedSourceType, elementName[1]); + } + int actualScopeSize = Iterables.size(getScopeProvider().getScope(context, reference).getAllElements()); + assertEquals(elementNames.length, actualScopeSize, NUMBER_OF_ELEMENTS_MESSAGE); + } + + /** + * Asserts the scope for the given context, reference, source type, and elements. + * + * @param context + * the context object + * @param reference + * the reference feature + * @param expectedSourceType + * the source-type name + * @param elements + * list of triples with the expected elements, each triple ordered as: {element name, source name, URI fragment} + */ + protected void assertScopeForElements(final EObject context, final EReference reference, final String expectedSourceType, final List> elements) { + Iterable allElements = getScopeProvider().getScope(context, reference).getAllElements(); + + // create a set containing the URIs (to avoid counting any duplicates the scope provider might have delivered) + Set uris = new HashSet(); + for (IEObjectDescription d : allElements) { + uris.add(d.getEObjectURI()); + } + + int actualScopeSizeWithoutDuplicates = uris.size(); + assertEquals(elements.size(), actualScopeSizeWithoutDuplicates, NUMBER_OF_ELEMENTS_MESSAGE); + for (Triple elementName : elements) { + assertScopeForElement(context, reference, elementName.getFirst(), elementName.getSecond(), expectedSourceType, elementName.getThird()); + } + } + + /** + * Asserts that the scope of the reference in the given context contains exactly the given sources. + * + * @param scopeContext + * the context of the scope test + * @param reference + * the reference to check its scope for + * @param modelElementClass + * the {@link EClass} of the model element to find + * @param sources + * the array of sources + */ + protected void assertScopeForSources(final EObject scopeContext, final EReference reference, final EClass modelElementClass, final String... sources) { + assertScope(scopeContext, reference, getExpectedURIs(scopeContext, modelElementClass, sources)); + int actualScopeSize = Iterables.size(getScopeProvider().getScope(scopeContext, reference).getAllElements()); + assertEquals(sources.length, actualScopeSize, NUMBER_OF_ELEMENTS_MESSAGE); + } + + /** + * Returns the expected uris for the given sources in the given context. + * + * @param context + * the context + * @param modelElementClass + * the class of the exported model element + * @param sources + * the sources to get the uris for + * @return the expected uris for the given sources in the given context + */ + private Set getExpectedURIs(final EObject context, final EClass modelElementClass, final String... sources) { + Set expectedURIs = new HashSet(); + for (String source : sources) { + expectedURIs.add(Iterables.get(getExportedObjects(context, modelElementClass, source), 0).getEObjectURI()); + } + return expectedURIs; + } + + /** + * Gets the exported objects. + * + * @param context + * the context + * @param type + * the type + * @param resourcePattern + * the resource pattern + * @return the exported objects + */ + public Iterable getExportedObjects(final EObject context, final EClass type, final String resourcePattern) { + Pattern regexp = Regexps.fromGlob(URI.encodeSegment(resourcePattern, true)); + return Iterables.filter(ContainerQuery.newBuilder(domainMapper, type).execute(context), (o) -> regexp.matcher(o.getEObjectURI().lastSegment()).matches()); + } + + /** + * Gets the exported names. + * + * @param execute + * the execute + * @return the exported names + */ + public List getExportedNames(final Iterable execute) { + return Lists.newArrayList(Iterables.transform(execute, new Function() { + @Override + public String apply(final IEObjectDescription from) { + return from.getName().toString(); + } + })); + } + + /** + * Checks if an object with given name (case sensitive) and type is exported. + * + * @param context + * the context + * @param name + * the name + * @param type + * the type + * @return true, if is exported + */ + public boolean isExported(final EObject context, final String name, final EClass type) { + return isExported(context, name, type, false); + } + + /** + * Checks if an object with given name, case sensitive or not, and type is exported. + * + * @param context + * the context + * @param name + * the name + * @param type + * the type + * @param ignoreCase + * the ignore case + * @return true, if is exported + */ + public boolean isExported(final EObject context, final String name, final EClass type, final boolean ignoreCase) { + List exportedNames = getExportedNames(ContainerQuery.newBuilder(domainMapper, type).execute(context)); + if (ignoreCase) { + return Iterables.contains(Iterables.transform(exportedNames, new Function() { + @Override + public String apply(final String from) { + return from.toLowerCase(); // NOPMD + } + }), name); + } else { + return exportedNames.contains(name); + } + } + + /** + * Gets the resource description for a given Xtext resource. + * + * @param resource + * the resource + * @return the resource description + */ + protected final IResourceDescription getResourceDescription(final XtextResource resource) { + final IResourceServiceProvider resourceServiceProvider = resource.getResourceServiceProvider(); + final IResourceDescription.Manager descriptionManager = resourceServiceProvider.getResourceDescriptionManager(); + return descriptionManager.getResourceDescription(resource); + } + + /** + * Gets the fingerprint for a given resource description, returns null if resource description does not export any objects or if a non-existing + * user data field was queried. + * + * @param description + * the description + * @return the fingerprint or null if no fingerprint found + */ + protected String getFingerprint(final IResourceDescription description) { + Iterable objects = description.getExportedObjects(); + if (!Iterables.isEmpty(objects)) { + IEObjectDescription objectDescription = Iterables.get(objects, 0); + return objectDescription.getUserData(IFingerprintComputer.RESOURCE_FINGERPRINT); + } + return null; + } + + /** + * Creates a top level URI fragment with a leading segment separator from the given segments + * taking into account repetitions. + * + * @param segments + * list of feature IDs, indexes (for multi valued features), and other fragment segments + * @return URI fragment + */ + public static String createTopLevelURIFragment(final Object... segments) { + return createURIFragment(true, segments); + } + + /** + * Creates a URI fragment from the given segments taking into account repetitions. + * + * @param segments + * list of feature IDs, indexes (for multi valued features), and other fragment segments + * @return URI fragment + */ + public static String createURIFragment(final Object... segments) { + return createURIFragment(false, segments); + } + + /** + * Creates a URI fragment from the given segments taking into account repetitions. + * + * @param topLevel + * whether the fragment is top level resulting in a leading segment separator. + * @param segments + * list of feature IDs, indexes (for multi valued features), and other fragment segments + * @return URI fragment + */ + @SuppressWarnings("PMD.UnusedPrivateMethod") + private static String createURIFragment(final boolean topLevel, final Object... segments) { + StringBuilder b = new StringBuilder(); + if (segments.length == 0) { + return b.toString(); + } + if (topLevel) { + b.append(SEGMENT_SEPARATOR); + } + List parsedSegments = Lists.newArrayList(); + for (Object segment : segments) { + Iterables.addAll(parsedSegments, FRAGMENT_SEGMENT_SPLITTER.split(segment.toString())); + } + + String lastSegment = parsedSegments.get(0); + int reps = 1; + for (int i = 1; i < parsedSegments.size(); i++) { + if (parsedSegments.get(i).equals(lastSegment)) { + reps++; + continue; + } + b.append(lastSegment); + if (reps > 1) { + b.append(REP_SEPARATOR).append(reps); + reps = 1; + } + b.append(SEGMENT_SEPARATOR); + lastSegment = parsedSegments.get(i); + } + b.append(lastSegment); + if (reps > 1) { + b.append(REP_SEPARATOR).append(reps); + } + return b.toString(); + } + + /** + * Creates a URI fragment list segment for the given feature selection string and list index. + * + * @param feature + * the feature selection string, must not be {@code null} or empty + * @param index + * the list index, must not be negative + * @return the URI fragment list segment, never {@code null} or empty + */ + public static String listFragmentSegment(final String feature, final int index) { + return feature + AbstractFragmentProvider.LIST_SEPARATOR + index; + } + + /** + * Creates a URI fragment list segment for the given feature id and list index. + * + * @param featureId + * the featureId, must not be negative + * @param index + * the list index, must not be negative + * @return the URI fragment list segment, never {@code null} or empty + */ + public static String listFragmentSegment(final int featureId, final int index) { + return listFragmentSegment(String.valueOf(featureId), index); + } + + /** + * Creates a URI fragment segment to be used for languages using the {@code AbstractSelectorFragmentProvider}. + * + * @param containmentFeature + * containment feature + * @param selectorFeature + * selector feature + * @param value + * value for selector feature + * @param unique + * if value is unique + * @return URI fragment segment + */ + public static String selectorFragmentSegment(final int containmentFeature, final int selectorFeature, final String value, final boolean unique) { + StringBuilder builder = new StringBuilder(); + builder.append(containmentFeature).append(SELECTOR_START).append(selectorFeature).append(EQ_OP).append(VALUE_SEP).append(value).append(VALUE_SEP); + if (unique) { + builder.append(UNIQUE); + } + builder.append(SELECTOR_END); + return builder.toString(); + } + + /** + * Creates an expectation of a link. Use this method in tests to insert an expectation that a cross reference does actually point to the object tagged by the + * target tag. Expectations can be tested by calling {@link #testExpectedLinking()}. Implicit items will be traversed. + * + * @see #mark(int) + * @see #testLinking(String, CharSequence) + * @param targetTag + * Tag pointing to the destination object + * @return Mark text to be inserted in the source file, never {@code null} + */ + protected String link(final int targetTag) { + return link(() -> getObjectForTag(targetTag)); + } + + /** + * Creates an expectation of a link. Use this method in tests to insert an expectation that a cross reference does actually point to the object tagged by the + * target tag. Expectations can be tested by calling {@link #testExpectedLinking()}. Implicit items will be traversed. + * + * @see #mark(int) + * @see #testLinking(String, CharSequence) + * @param getTargetObject + * supplier to get the destination object, must not be {@code null} + * @return Mark text to be inserted in the source file, never {@code null} + */ + protected String link(final Supplier getTargetObject) { + final int sourceTag = getTag(); + expectedLinkAssertions.add(() -> testLinking(sourceTag, getTargetObject.get())); + return mark(sourceTag); + } + + /** + * Performs linking test. Checks expectations which were set in a source using {@link #link(int)} or {@link #link(Function, int). + * + * @see #link(int) + * @see #link(Supplier) + * @see #testLinking(int, EObject) + * @param sourceFileNameAndContent + * the file name and content, given as the key and value of the pair, respectively, must not be {@code null} + */ + protected void testLinking(final Pair sourceFileNameAndContent) { + testLinking(sourceFileNameAndContent.getKey(), sourceFileNameAndContent.getValue()); + } + + /** + * Performs linking test. Checks expectations which were set in a source using {@link #link(int)} or {@link #link(Function, int). + * + * @see #link(int) + * @see #link(Supplier) + * @see #testLinking(int, EObject) + * @param sourceFileName + * the file name that should be associated with the parsed content, must not be {@code null} + * @param sourceContent + * source, must not be {@code null} + */ + protected void testLinking(final String sourceFileName, final CharSequence sourceContent) { + registerModel(sourceFileName, sourceContent); + expectedLinkAssertions.forEach(Runnable::run); + expectedLinkAssertions.clear(); + } + + /** + * Performs linking test. Checks that given cross reference tagged with sourceTag does actually point to the object tagged by the target tag. + * Detailed error reporting can be viewed in a compare view. Implicit items will be traversed. + * + * @param sourceTag + * Tag pointing to cross reference + * @param targetTag + * Tag pointing to the destination object + */ + protected void testLinking(final int sourceTag, final int targetTag) { + testLinking(sourceTag, getObjectForTag(targetTag), true); + } + + /** + * Performs linking test. Checks that the cross reference marked with sourceTag does actually point to the object provided as the second argument. Implicit + * items will be traversed. + * + * @param sourceTag + * Tag pointing to a cross reference + * @param targetObject + * Expected target object + */ + protected void testLinking(final int sourceTag, final EObject targetObject) { + testLinking(sourceTag, targetObject, true); + } + + /** + * Performs linking test. Checks that the source object is the same as the object pointed by targetTag provided as the second argument. Implicit + * items will be traversed. + * + * @param sourceObject + * Source object + * @param targetTag + * Tag to the expected target object + */ + protected void testLinking(final EObject sourceObject, final int targetTag) { + testLinking(sourceObject, targetTag, true); + } + + /** + * Performs linking test. Checks that the cross reference marked with sourceTag does actually point to the object provided as the second argument. + * + * @param sourceTag + * Tag pointing to a cross reference + * @param targetObject + * Expected target object, must not be {@code null} + * @param traverseImplicitItems + * If target of a reference is an implicit item and this parameter is set to true, the test will get and compare the original object from which this + * implicit item was created + */ + protected void testLinking(final int sourceTag, final EObject targetObject, final boolean traverseImplicitItems) { + assertNotNull(targetObject, "Target object must not be null."); //$NON-NLS-1$ + CrossReference crossReference = getMarkerTagsInfo().getCrossReference(sourceTag); + EObject referencedSourceObject = getCrossReferencedObject(sourceTag, traverseImplicitItems, crossReference); + assertEObjectsAreEqual(referencedSourceObject, targetObject, crossReference); + } + + /** + * Performs linking test. Checks that the source object is the same as the object pointed by targetTag provided as the second argument. + * Does not deal with cross-referencing. + * + * @param sourceObject + * Source object, must not be {@code null} + * @param targetTag + * Tag to the referenced target object + * @param traverseImplicitItems + * If target of a reference is an implicit item and this parameter is set to true, the test will get and compare the original object from which this + * implicit item was created + */ + protected void testLinking(final EObject sourceObject, final int targetTag, final boolean traverseImplicitItems) { + assertNotNull(sourceObject, "Source object must not be null."); //$NON-NLS-1$ + EObject referencedTargetObject = getObjectForTag(targetTag); + assertEObjectsAreEqual(sourceObject, referencedTargetObject, null); + } + + /** + * Asserts whether the two objects are equal. + * + * @param sourceObject + * First object needed for comparison. + * @param targetObject + * Target object needed for comparison. + * @param crossReference + * CrossReference object, can be {@code null} + */ + protected void assertEObjectsAreEqual(final EObject sourceObject, final EObject targetObject, final CrossReference crossReference) { + StringBuilder expected = new StringBuilder(); + StringBuilder found = new StringBuilder(); + if (crossReference != null) { + String crossReferenceText = "Cross reference:\n" + crossReference.toString() + "\n"; //$NON-NLS-1$ //$NON-NLS-2$ + expected.append(crossReferenceText); + found.append(crossReferenceText); + } + expected.append(LINKS_TO); + found.append(LINKS_TO); + URI targetUri = EcoreUtil.getURI(targetObject); + expected.append(targetUri); + String sourceObjectUri = EcoreUtil.getURI(sourceObject).toString(); + found.append(sourceObjectUri); + expected.append(WHICH_CORRESPONDS_TO); + INode node; + node = NodeModelUtils.findActualNodeFor(targetObject); + if (node != null) { + expected.append(NodeModelUtils.getTokenText(node)); + } else { + expected.append(NO_NODE_MODEL_COULD_BE_A_DERIVED_OBJECT); + } + found.append(WHICH_CORRESPONDS_TO); + node = NodeModelUtils.findActualNodeFor(sourceObject); + if (sourceObject.eIsProxy()) { + found.append(UNRESOLVED_REFERENCE); + } else if (node != null) { + found.append(NodeModelUtils.getTokenText(node)); + } else { + found.append(NO_NODE_MODEL_COULD_BE_A_DERIVED_OBJECT); + } + assertEquals(expected.toString(), found.toString(), "Errors found. Consider compare view."); //$NON-NLS-1$ + } + + /** + * Returns the referenced {@link EObject} pointed to by the cross reference. + *

+ * Note: For implicit item traversal to work, a custom implementation must be provided by overriding this method. + *

+ * + * @param sourceTag + * the source tag + * @param traverseImplicitItems + * If target of a reference is an implicit item and this parameter is set to true, the test will get and compare the original object from which this + * implicit item was created. + * @param crossReference + * Cross reference to be resolved, must not be {@code null} + * @return the referenced {@link EObject}, must not be {@code null} + */ + protected EObject getCrossReferencedObject(final int sourceTag, final boolean traverseImplicitItems, final CrossReference crossReference) { + EObject context = getObjectForTag(sourceTag); + if (crossReference == null) { + throw new IllegalArgumentException(NLS.bind("Cross reference on object ''{0}'' could not be resolved.", context.toString())); //$NON-NLS-1$ + } + // We only handle references in assignments + Assignment assignment = EcoreUtil2.getContainerOfType(crossReference, Assignment.class); + EObject sourceObject; + String featureName = assignment.getFeature(); + EReference reference = (EReference) context.eClass().getEStructuralFeature(featureName); + if (reference.isMany()) { + Object featureValue = context.eGet(reference, false); + assertTrue(featureValue instanceof EObjectResolvingEList, "List must be of type EObjectResolvingEList"); //$NON-NLS-1$ + @SuppressWarnings("unchecked") + EList objects = (EObjectResolvingEList) context.eGet(reference, false); + if (objects.size() == 1) { + sourceObject = EcoreUtil.resolve(objects.get(0), context); + } else { + // TODO DSL-166: Handle this case when needed for tests. + throw new AssertionError("Multiple references not supported yet"); //$NON-NLS-1$ + } + } else { + sourceObject = (EObject) context.eGet(reference, true); + } + assertNotNull(sourceObject, "Bad test. Referenced object is null."); //$NON-NLS-1$ + return sourceObject; + } +} diff --git a/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractValidationTest.java b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractValidationTest.java new file mode 100644 index 0000000000..c248425e23 --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractValidationTest.java @@ -0,0 +1,1218 @@ +/******************************************************************************* + * Copyright (c) 2025 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.xtext.test.jupiter; + +import static org.eclipse.xtext.validation.ValidationMessageAcceptor.INSIGNIFICANT_INDEX; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.eclipse.emf.common.util.BasicDiagnostic; +import org.eclipse.emf.common.util.Diagnostic; +import org.eclipse.emf.common.util.EList; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.osgi.util.NLS; +import org.eclipse.xtext.EcoreUtil2; +import org.eclipse.xtext.diagnostics.AbstractDiagnostic; +import org.eclipse.xtext.linking.impl.XtextLinkingDiagnostic; +import org.eclipse.xtext.nodemodel.ICompositeNode; +import org.eclipse.xtext.nodemodel.INode; +import org.eclipse.xtext.nodemodel.util.NodeModelUtils; +import org.eclipse.xtext.resource.XtextResource; +import org.eclipse.xtext.resource.XtextSyntaxDiagnostic; +import org.eclipse.xtext.util.CancelIndicator; +import org.eclipse.xtext.validation.AbstractValidationDiagnostic; +import org.eclipse.xtext.validation.FeatureBasedDiagnostic; +import org.eclipse.xtext.validation.RangeBasedDiagnostic; +import org.eclipse.xtext.xbase.lib.Pair; + +import com.avaloq.tools.ddk.xtext.test.XtextTestSource; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import com.google.inject.Provider; + + +/** + * Base class for validation tests. + */ +@SuppressWarnings({"nls", "PMD.ExcessiveClassLength"}) +// CHECKSTYLE:CHECK-OFF MultipleStringLiterals +// CHECKSTYLE:OFF MagicNumber +public abstract class AbstractValidationTest extends AbstractXtextMarkerBasedTest { + + public static final int NO_ERRORS = 0; + + static final String NO_ERRORS_FOUND_ON_RESOURCE_MESSAGE = "Expecting no errors on resource"; + + private static final int SEVERITY_UNDEFINED = -1; + private static final Map CODE_TO_NAME = ImmutableMap.of(Diagnostic.INFO, "INFO", Diagnostic.WARNING, "WARNING", Diagnostic.ERROR, "ERROR"); + + private static final String LINE_BREAK = "\n"; + private static final String DOT_AND_LINEBREAK = "'." + LINE_BREAK; + + /** + * All diagnostics of the current testing file. + */ + private Diagnostic fileDiagnostics; + + /** + * During validation of a source we monitor diagnostics, that are found in the source but were not expected by the test. + * If the validation test is strict, then we will display these unexpected diagnostics as test error. + */ + private final Set unexpectedDiagnostics = Sets.newLinkedHashSet(); + private final Set unexpectedResourceDiagnostics = Sets.newLinkedHashSet(); + + /** + * validation results calculated during test setUp. + * + * @return the diagnostic for the primary test source file + */ + private Diagnostic getPrimaryDiagnostics() { + Object obj = getTestInformation().getTestObject(Diagnostic.class); + assertNotNull(obj, "getPrimaryDiagnostics(): Diagnostics of primary source not null."); + return (Diagnostic) obj; + } + + /** + * Returns the unexpectedDiagnostics. + * + * @return the unexpectedDiagnostics + */ + protected Set getUnexpectedDiagnostics() { + return unexpectedDiagnostics; + } + + /** + * Returns the unexpectedDiagnostics. + * + * @return the unexpectedDiagnostics + */ + protected Set getUnexpectedResourceDiagnostics() { + return unexpectedResourceDiagnostics; + } + + /** + * Assertion testing for {@link AbstractValidationDiagnostic validation issues} at a given source position. + */ + protected class XtextDiagnosticAssertion extends AbstractModelAssertion { + + /** Issue code of the diagnostic. */ + private final String issueCode; + /** Issue message of the diagnostic. */ + private final String message; + /** + * Indicates whether the assertion must find the issue. + * Assertion creates an error if the existence of issue code for the target eobject doesn't correspond to the value of issueMustBeFound. + */ + private final boolean issueMustBeFound; + + private final int expectedSeverity; + + protected XtextDiagnosticAssertion(final String issueCode, final boolean issueMustBeFound) { + this(issueCode, issueMustBeFound, SEVERITY_UNDEFINED, null); + } + + protected XtextDiagnosticAssertion(final String issueCode, final boolean issueMustBeFound, final int severity, final String message) { + this.issueCode = issueCode; + this.issueMustBeFound = issueMustBeFound; + this.expectedSeverity = severity; + this.message = message; + } + + /** + * Check if the given issue code is found among issue codes for the object, located at the given position. + * + * @param root + * root object of the document + * @param pos + * position to locate the target object + */ + @Override + public void apply(final EObject root, final Integer pos) { + final Diagnostic diagnostics = validate(root); + final BasicDiagnostic diagnosticsOnTargetPosition = new BasicDiagnostic(); + boolean issueFound = false; + int actualSeverity = SEVERITY_UNDEFINED; + boolean expectedSeverityMatches = false; + boolean expectedMessageMatches = false; + String actualMessage = ""; + + for (AbstractValidationDiagnostic avd : Iterables.filter(diagnostics.getChildren(), AbstractValidationDiagnostic.class)) { + if (diagnosticPositionEquals(pos, avd)) { + // Add issue to the list of issues at the given position + diagnosticsOnTargetPosition.add(avd); + if (avd.getIssueCode().equals(issueCode)) { + issueFound = true; + actualSeverity = avd.getSeverity(); + // True if the expected severity is not set, or if matches with the actual one + expectedSeverityMatches = expectedSeverity == SEVERITY_UNDEFINED || expectedSeverity == actualSeverity; + actualMessage = avd.getMessage(); + // True if message matches with actual message or message is null + expectedMessageMatches = message == null || actualMessage.equals(message); + if (issueMustBeFound) { + // Remove the diagnostic from the list of non-expected diagnostics + getUnexpectedDiagnostics().remove(avd); + // Don't need to display error messages + if (expectedSeverityMatches && expectedMessageMatches) { + return; + } + } + } + } + } + + // Create error message + createErrorMessage(pos, diagnosticsOnTargetPosition, issueFound, expectedSeverityMatches, actualSeverity, expectedMessageMatches, actualMessage); + } + + /** + * Create an error message (if needed) based on the given input parameters. + * + * @param pos + * position in the source to associate the message with + * @param diagnosticsOnTargetPosition + * diagnostics on the specifies position + * @param issueFound + * specifies whether an issue has been found at the given position + * @param expectedSeverityMatches + * true if expected severity equals actual one, false otherwise + * @param actualSeverity + * actual severity + * @param expectedMessageMatches + * expected message matches + * @param actualMessage + * actual message + */ + private void createErrorMessage(final Integer pos, final BasicDiagnostic diagnosticsOnTargetPosition, final boolean issueFound, final boolean expectedSeverityMatches, final int actualSeverity, final boolean expectedMessageMatches, final String actualMessage) { + StringBuilder errorMessage = new StringBuilder(180); + if (issueMustBeFound && !issueFound) { + errorMessage.append("Expected issue not found. Code '").append(issueCode).append('\n'); + } else if (!issueMustBeFound && issueFound) { + errorMessage.append("There should be no issue with the code '").append(issueCode).append(DOT_AND_LINEBREAK); + } + if (issueFound && !expectedMessageMatches) { + errorMessage.append("Expected message does not match. Expected: '").append(message).append("', Actual: '").append(actualMessage).append('\n'); + } + // If the expected issue has been found, but the actual severity does not match with expected one + if (issueMustBeFound && issueFound && !expectedSeverityMatches) { + errorMessage.append("Severity does not match. Expected: ").append(CODE_TO_NAME.get(expectedSeverity)).append(". Actual: ").append(CODE_TO_NAME.get(actualSeverity)).append(".\n"); + } + // Memorize error message + if (errorMessage.length() > 0) { + if (!diagnosticsOnTargetPosition.getChildren().isEmpty()) { + errorMessage.append(" All issues at this position:\n"); + errorMessage.append(diagnosticsToString(diagnosticsOnTargetPosition, false)); + } + memorizeErrorOnPosition(pos, errorMessage.toString()); + } + } + + /** + * Compare if the position of the given diagnostic equals to the given position in text. + * + * @param pos + * position in text + * @param avd + * diagnostic that we check, if it has the same position as the given position in text + * @return + * TRUE if diagnostic has the same position as the given one, FALSE otherwise. + */ + protected boolean diagnosticPositionEquals(final Integer pos, final AbstractValidationDiagnostic avd) { + if (avd instanceof FeatureBasedDiagnostic && ((FeatureBasedDiagnostic) avd).getFeature() != null) { + List nodes = NodeModelUtils.findNodesForFeature(avd.getSourceEObject(), ((FeatureBasedDiagnostic) avd).getFeature()); + if (nodes.isEmpty()) { + INode node = NodeModelUtils.getNode(avd.getSourceEObject()); + INode firstNonHiddenLeafNode = getXtextTestUtil().findFirstNonHiddenLeafNode(node); + if (firstNonHiddenLeafNode == null) { + return issueMustBeFound; + } else if (firstNonHiddenLeafNode.getTotalOffset() == pos) { + return true; + } + } else { + int avdIndex = ((FeatureBasedDiagnostic) avd).getIndex(); + for (int i = 0; i < nodes.size(); i++) { + if (avdIndex == INSIGNIFICANT_INDEX || avdIndex == i) { + INode firstNonHiddenLeafNode = getXtextTestUtil().findFirstNonHiddenLeafNode(nodes.get(i)); + if (firstNonHiddenLeafNode == null) { + return issueMustBeFound; + } else if (firstNonHiddenLeafNode.getTotalOffset() == pos) { + return true; + } + } + } + } + } else if (avd instanceof RangeBasedDiagnostic) { + if (((RangeBasedDiagnostic) avd).getOffset() == pos) { + return true; + } + } else { + INode node = NodeModelUtils.getNode(avd.getSourceEObject()); + INode firstNonHiddenLeafNode = getXtextTestUtil().findFirstNonHiddenLeafNode(node); + if (firstNonHiddenLeafNode == null) { + return issueMustBeFound; + } else if (firstNonHiddenLeafNode.getTotalOffset() == pos) { + return true; + } + } + return false; + } + } + + /** + * Assertion testing for {@link AbstractValidationDiagnostic validation issues} at a given source position. + */ + private class ResourceDiagnosticAssertion extends AbstractModelAssertion { + + /** Issue code of the diagnostic. */ + private final String issueCode; + /** Issue message of the diagnostic. */ + private final String message; + /** + * Indicates whether the assertion must find the issue. + * Assertion creates an error if the existence of issue code for the target eobject doesn't correspond to the value of issueMustBeFound. + */ + private final boolean issueMustBeFound; + + private final int expectedSeverity; + + protected ResourceDiagnosticAssertion(final String issueCode, final boolean issueMustBeFound, final int severity, final String message) { + this.issueCode = issueCode; + this.issueMustBeFound = issueMustBeFound; + this.expectedSeverity = severity; + this.message = message; + } + + /** + * Check if the given issue code is found among issue codes for the object, located at the given position. + * + * @param root + * root object of the document + * @param pos + * position to locate the target object + */ + @Override + public void apply(final EObject root, final Integer pos) { + Iterable diagnostics = null; + switch (expectedSeverity) { + case Diagnostic.ERROR: + diagnostics = root.eResource().getErrors(); + break; + case Diagnostic.WARNING: + diagnostics = root.eResource().getWarnings(); + break; + case SEVERITY_UNDEFINED: + diagnostics = Iterables.concat(root.eResource().getErrors(), root.eResource().getWarnings()); + break; + } + final List diagnosticsOnTargetPosition = Lists.newArrayList(); + boolean issueFound = false; + int actualSeverity = expectedSeverity; + boolean expectedMessageMatches = false; + String actualMessage = ""; + + for (AbstractDiagnostic diag : Iterables.filter(diagnostics, AbstractDiagnostic.class)) { + if (diagnosticPositionEquals(pos, diag)) { + // Add issue to the list of issues at the given position + diagnosticsOnTargetPosition.add(diag); + if (diag.getCode() != null && diag.getCode().equals(issueCode)) { + issueFound = true; + if (expectedSeverity == SEVERITY_UNDEFINED) { + actualSeverity = root.eResource().getErrors().contains(diag) ? Diagnostic.ERROR : Diagnostic.WARNING; + } + actualMessage = diag.getMessage(); + // True if message matches with actual message or message is null + expectedMessageMatches = message == null || actualMessage.equals(message); + // Don't need to display error messages + if (issueMustBeFound) { + // Remove the diagnostic from the list of non-expected diagnostics + getUnexpectedResourceDiagnostics().remove(diag); + // Don't need to display error messages + if (expectedMessageMatches) { + return; + } + } + } + } + } + + // Create error message + createErrorMessage(pos, diagnosticsOnTargetPosition, issueFound, true, actualSeverity, expectedMessageMatches, actualMessage); + } + + /** + * Create an error message (if needed) based on the given input parameters. + * + * @param pos + * position in the source to associate the message with + * @param diagnosticsOnTargetPosition + * diagnostics on the specifies position + * @param issueFound + * specifies whether an issue has been found at the given position + * @param expectedSeverityMatches + * true if expected severity equals actual one, false otherwise + * @param actualSeverity + * actual severity + * @param expectedMessageMatches + * expected message matches + * @param actualMessage + * actual message + */ + private void createErrorMessage(final Integer pos, final List diagnosticsOnTargetPosition, final boolean issueFound, final boolean expectedSeverityMatches, final int actualSeverity, final boolean expectedMessageMatches, final String actualMessage) { + StringBuilder errorMessage = new StringBuilder(175); + if (issueMustBeFound && !issueFound) { + errorMessage.append("Expected issue not found. Code '").append(issueCode).append('\n'); + } else if (!issueMustBeFound && issueFound) { + errorMessage.append("There should be no issue with the code '").append(issueCode).append(DOT_AND_LINEBREAK); + } + if (issueFound && !expectedMessageMatches) { + errorMessage.append("Expected message does not match. Expected: '").append(message).append("', Actual: '").append(actualMessage).append('\n'); + } + // If the expected issue has been found, but the actual severity does not match with expected one + if (issueMustBeFound && issueFound && !expectedSeverityMatches) { + errorMessage.append("Severity does not match. Expected: ").append(CODE_TO_NAME.get(expectedSeverity)).append(". Actual: ").append(CODE_TO_NAME.get(actualSeverity)).append(".\n"); + } + // Memorize error message + if (errorMessage.length() > 0) { + if (!diagnosticsOnTargetPosition.isEmpty()) { + errorMessage.append(" All issues at this position:\n"); + errorMessage.append(diagnosticsToString(diagnosticsOnTargetPosition, false)); + } + memorizeErrorOnPosition(pos, errorMessage.toString()); + } + } + + /** + * Compare if the position of the given diagnostic equals to the given position in text. + * + * @param pos + * position in text + * @param diagnostic + * diagnostic that we check, if it has the same position as the given position in text + * @return + * {@code true} if diagnostic has the same position as the given one, {@code false} otherwise. + */ + private boolean diagnosticPositionEquals(final Integer pos, final AbstractDiagnostic diagnostic) { + return diagnostic.getOffset() == pos; + } + } + + /** + * Get a cached version of an object associated with the root object for a given key. + * + * @param + * type of the associated object + * @param root + * root EObject + * @param key + * key identifying the type of the associated object + * @param provider + * provider to deliver an object if there is no cached version + * @return + * cached version of the associated object + */ + protected T getCached(final EObject root, final String key, final Provider provider) { + XtextResource res = (XtextResource) root.eResource(); + return res.getCache().get(key, res, provider); + } + + /** + * Validate the model. + * + * @param root + * root EObject to validate + * @return + * validation results + */ + protected Diagnostic validate(final EObject root) { + return getCached(root, "DIAGNOSTIC", () -> getXtextTestUtil().getDiagnostician().validate(root)); + } + + /** + * Display the path from root object to the target EObject. + * + * @param eObject + * object to display the object path for + * @param offset + * string offset that is added in the beginning of each line + * @return + * object hierarchy as string (each object on a single line) + */ + private String pathFromRootAsString(final EObject eObject, final String offset) { + List hierarchy = Lists.newLinkedList(); + + EObject currentObject = eObject; + while (currentObject != null) { + hierarchy.add(0, offset + currentObject.toString()); + currentObject = currentObject.eContainer(); + } + + return String.join("\n", hierarchy); + } + + /** + * Persist list diagnostics into string to display the list of issue codes. + * + * @param diagnostics + * list of diagnostics + * @param displayPathToTargetObject + * if true, the path through the object hierarchy is printed out up to the root node + * @return + * string with list of issue codes, separated with a line break + */ + // TODO (ACF-4153) generalize for all kinds of errors and move to AbstractXtextTest + private String diagnosticsToString(final Diagnostic diagnostics, final boolean displayPathToTargetObject) { + StringBuilder sb = new StringBuilder(); + for (Diagnostic diagnostic : diagnostics.getChildren()) { + if (diagnostic instanceof AbstractValidationDiagnostic) { + AbstractValidationDiagnostic avd = (AbstractValidationDiagnostic) diagnostic; + sb.append(" "); + sb.append(avd.getIssueCode()); + if (displayPathToTargetObject) { + sb.append(" at line: "); + ICompositeNode compositeNode = NodeModelUtils.findActualNodeFor(avd.getSourceEObject()); + if (compositeNode != null) { + sb.append(compositeNode.getStartLine()); + } else { + sb.append("Unknown"); + } + sb.append(" on \n"); + sb.append(pathFromRootAsString(avd.getSourceEObject(), " ")); + } + sb.append(LINE_BREAK); + } + } + return sb.toString(); + } + + /** + * Persist list diagnostics into string to display the list of issue codes. + * + * @param diagnostics + * list of diagnostics + * @param displayPathToTargetObject + * if true, the path through the object hierarchy is printed out up to the root node + * @return + * string with list of issue codes, separated with a line break + */ + // TODO (ACF-4153) generalize for all kinds of errors and move to AbstractXtextTest + private String diagnosticsToString(final List diagnostics, final boolean displayPathToTargetObject) { + StringBuilder sb = new StringBuilder(25); + for (Resource.Diagnostic diagnostic : diagnostics) { + if (diagnostic instanceof AbstractDiagnostic) { + AbstractDiagnostic diag = (AbstractDiagnostic) diagnostic; + sb.append(" "); + sb.append(diag.getCode()); + if (displayPathToTargetObject) { + sb.append(" at line: "); + sb.append(diag.getLine()); + sb.append(" on \n"); + sb.append(" "); + sb.append(diag.getUriToProblem()); + } + sb.append(LINE_BREAK); + } + } + return sb.toString(); + } + + @Override + protected void beforeAllTests() { + super.beforeAllTests(); + if (getTestSource() != null) { + Diagnostic primaryDiagnostics = getXtextTestUtil().getDiagnostician().validate(getSemanticModel()); + getTestInformation().putTestObject(Diagnostic.class, primaryDiagnostics); + } + } + + /** + * Register a new validation marker with the given issue code. Expects an info. + * + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String info(final String issueCode) { + return info(issueCode, null); + } + + /** + * Register a new validation marker with the given issue code and message. Expects an info. + * + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @param message + * the expected issue message + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String info(final String issueCode, final String message) { + return addAssertion(new XtextDiagnosticAssertion(issueCode, true, Diagnostic.INFO, message)); + } + + /** + * Register a new validation marker with the given issue code. Expects a warning if the condition is {@code true}, no diagnostic otherwise. + * + * @param condition + * the condition when the marker is expected + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String warningIf(final boolean condition, final String issueCode) { + if (condition) { + return warning(issueCode); + } else { + return noDiagnostic(issueCode); + } + } + + /** + * Register a new validation marker with the given issue code. Expects a warning. + * + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String warning(final String issueCode) { + return warning(issueCode, null); + } + + /** + * Register a new validation marker with the given issue code and message. Expects a warning. + * + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @param message + * the expected issue message + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String warning(final String issueCode, final String message) { + return addAssertion(new XtextDiagnosticAssertion(issueCode, true, Diagnostic.WARNING, message)); + } + + /** + * Register a new validation marker with the given issue code. Expects an error if the condition is {@code true}, no diagnostic otherwise. + * + * @param condition + * the condition when the marker is expected + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String errorIf(final boolean condition, final String issueCode) { + if (condition) { + return error(issueCode); + } else { + return noDiagnostic(issueCode); + } + } + + /** + * Register a new validation marker with the given issue code. Expects an error. + * + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String error(final String issueCode) { + return error(issueCode, null); + } + + /** + * Register a new validation marker with the given issue code and message. Expects an error. + * + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @param message + * the expected issue message + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String error(final String issueCode, final String message) { + return addAssertion(new XtextDiagnosticAssertion(issueCode, true, Diagnostic.ERROR, message)); + } + + /** + * Register a new validation marker with the given issue code. + * The issue is expected to be found in the test file. + * + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String diagnostic(final String issueCode) { + return diagnostic(issueCode, null); + } + + /** + * Register a new validation marker with the given issue code and message. + * The issue and message are expected to be found in the test file. + * + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @param message + * the expected issue message + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String diagnostic(final String issueCode, final String message) { + return addAssertion(new XtextDiagnosticAssertion(issueCode, true, SEVERITY_UNDEFINED, message)); + } + + /** + * Register a new linking error validation marker. + * The issue is expected to be found in the test file. + * + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String linkingError() { + return linkingError(null); + } + + /** + * Register a new linking error validation marker with the given message. + * The issue is expected to be found in the test file. + * + * @param message + * issuethe expected issue message + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String linkingError(final String message) { + return addAssertion(new ResourceDiagnosticAssertion(org.eclipse.xtext.diagnostics.Diagnostic.LINKING_DIAGNOSTIC, true, Diagnostic.ERROR, message)); + } + + /** + * Register a new resource validation marker with the given issue code and message. + * The issue is expected to be found in the test file. + * + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @param message + * the expected issue message + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String resourceDiagnostic(final String issueCode, final String message) { + return addAssertion(new ResourceDiagnosticAssertion(issueCode, true, Diagnostic.ERROR, message)); + } + + /** + * Register a new validation marker with the given issue code. + * The issue is expected NOT to be found in the test file. + * + * @param issueCode + * issue code (usually found as static constant of the JavaValidator class of the DSL being tested) + * @return + * unique marker that can be used in the input string to mark a position that should be validated + */ + protected String noDiagnostic(final String issueCode) { + return addAssertion(new XtextDiagnosticAssertion(issueCode, false)); + } + + @Override + protected void beforeApplyAssertions(final XtextTestSource testSource) { + super.beforeApplyAssertions(testSource); + EObject root = testSource.getModel(); + // Get all diagnostics of the current testing file + EcoreUtil2.resolveLazyCrossReferences(root.eResource(), CancelIndicator.NullImpl); + fileDiagnostics = validate(root); + getUnexpectedDiagnostics().addAll(fileDiagnostics.getChildren()); + getUnexpectedResourceDiagnostics().addAll(root.eResource().getErrors()); + getUnexpectedResourceDiagnostics().addAll(root.eResource().getWarnings()); + } + + @Override + protected String getAdditionalErrorMessageInformation() { + return diagnosticsToString(fileDiagnostics, true); + } + + @Override + protected void afterValidate() { + super.afterValidate(); + // Garbage collection + getUnexpectedDiagnostics().clear(); + getUnexpectedResourceDiagnostics().clear(); + } + + /** + * Assert that diagnosticList contains a diagnostic of the given issueCode. + * + * @param issueCode + * the code of the issue to look for + */ + protected void assertDiagnostic(final String issueCode) { + assertDiagnostic(getPrimaryDiagnostics(), issueCode); + } + + /** + * Assert that the given EObject model contains a diagnostic of the given issueCode. + * + * @param model + * the model in which to look for issues, may be {@code null} + * @param issueCode + * the code of the issue to look for + */ + protected void assertDiagnostic(final EObject model, final String issueCode) { + assertNotNull(model, "Issue with code '" + issueCode + "' cannot be found because the model is null"); + assertDiagnostic(getXtextTestUtil().getDiagnostician().validate(model), issueCode); + } + + /** + * Assert that diagnosticList does not contain a diagnostic of the given issueCode. + * + * @param issueCode + * the code of the issue to look for + */ + protected void assertNoDiagnostic(final String issueCode) { + assertNoDiagnostic(getPrimaryDiagnostics(), issueCode); + } + + /** + * Assert that the given EObject model does not contain a diagnostic of the given issueCode. + * + * @param model + * the model in which to look for issues, may be {@code null} + * @param issueCode + * the code of the issue to look for + */ + protected void assertNoDiagnostic(final EObject model, final String issueCode) { + assertNotNull(model, "Issue with code '" + issueCode + "' cannot be found because the model is null"); + assertNoDiagnostic(getXtextTestUtil().getDiagnostician().validate(model), issueCode); + } + + /** + * Assert that diagnosticList does not contain any diagnostic. + */ + protected void assertNoDiagnostics() { + assertNoDiagnostics(getPrimaryDiagnostics()); + } + + /** + * Assert that the given EObject model does not contain any diagnostic. + * + * @param model + * the model in which to look for issues, may be {@code null} + */ + protected void assertNoDiagnostics(final EObject model) { + assertNotNull(model, "Assertion cannot be checked because the model is null"); + assertNoDiagnostics(getXtextTestUtil().getDiagnostician().validate(model)); + } + + /** + * Assert that diagnosticList contains a diagnostic with the given message. + * + * @param message + * the message of the issue to look for + */ + protected void assertDiagnosticMessage(final String message) { + assertDiagnosticMessage(getPrimaryDiagnostics(), message); + } + + /** + * Assert that the given EObject model contains a diagnostic with the given message. + * + * @param model + * the model in which to look for issues, may be {@code null} + * @param message + * the message of the issue to look for + */ + protected void assertDiagnosticMessage(final EObject model, final String message) { + assertNotNull(model, "Message '" + message + "' cannot be found because the model is null"); + assertDiagnosticMessage(getXtextTestUtil().getDiagnostician().validate(model), message); + } + + /** + * Assert that diagnosticList contains a diagnostic with the given message. + * + * @param diagnostics + * the diagnostic to check for issues + * @param message + * the message of the issue to look for + */ + private static void assertDiagnosticMessage(final Diagnostic diagnostics, final String message) { + for (Diagnostic diagnostic : diagnostics.getChildren()) { + if (diagnostic.getMessage().equals(message)) { + return; + } + } + fail("Issue with message ' " + message + "' not found"); + } + + /** + * Assert that diagnosticList contains a diagnostic of the given issueCode. + * + * @param diagnostics + * the diagnostic to check for issues + * @param issueCode + * the code of the issue to look for + */ + private void assertDiagnostic(final Diagnostic diagnostics, final String issueCode) { + for (Diagnostic diagnostic : diagnostics.getChildren()) { + if (diagnostic instanceof AbstractValidationDiagnostic && ((AbstractValidationDiagnostic) diagnostic).getIssueCode().equals(issueCode)) { + return; + } + } + fail("Issue with code '" + issueCode + "' not found"); + } + + /** + * Assert that diagnosticList contains a diagnostic of the given issueCode on a given EObject. + * For performance reasons one can validate the root object and afterwards use this method + * to check that a particular diagnostic exists on one of the child objects of the validated model. + * + * @param diagnostics + * the diagnostic to check for issues + * @param targetObject + * the object that should have a diagnostic with the given issueCode + * @param issueCode + * the code of the issue to look for + */ + protected void assertDiagnosticOnObject(final Diagnostic diagnostics, final EObject targetObject, final String issueCode) { + for (Diagnostic diagnostic : diagnostics.getChildren()) { + if (diagnostic instanceof AbstractValidationDiagnostic) { + AbstractValidationDiagnostic avd = (AbstractValidationDiagnostic) diagnostic; + if (avd.getSourceEObject() == targetObject && avd.getIssueCode().equals(issueCode)) { + return; + } + } + } + fail("Issue with code '" + issueCode + "' not found"); + } + + /** + * Assert that diagnosticList does not contain a diagnostic of the given issueCode. + * + * @param diagnostics + * the diagnostic to check for issues + * @param issueCode + * the code of the issue to look for + */ + private void assertNoDiagnostic(final Diagnostic diagnostics, final String issueCode) { + for (Diagnostic diagnostic : diagnostics.getChildren()) { + if (((AbstractValidationDiagnostic) diagnostic).getIssueCode().equals(issueCode)) { + fail("Issue with code '" + issueCode + "' found"); + return; + } + } + } + + /** + * Assert that diagnosticList does not contain any diagnostic. + * + * @param diagnostics + * the diagnostic to check for issues + */ + private void assertNoDiagnostics(final Diagnostic diagnostics) { + assertEquals(diagnostics.getCode(), Diagnostic.OK, "Diagnostics should be in OK state."); + assertTrue(diagnostics.getChildren().isEmpty(), "There should be no diagnostics. Instead found " + diagnostics.getChildren().size()); + } + + /** + * Assert no errors on resource exist. + * + * @param object + * the object + */ + public static void assertNoErrorsOnResource(final EObject object) { + final EList errors = object.eResource().getErrors(); + if (!errors.isEmpty()) { + fail(AbstractValidationTest.NO_ERRORS_FOUND_ON_RESOURCE_MESSAGE + "; found " + Lists.transform(errors, Resource.Diagnostic::getMessage)); //$NON-NLS-1$ + } + } + + /** + * Assert no errors on resource with the given message exist. + * + * @param object + * the object + * @param messages + * the messages + */ + public static void assertNoErrorsOnResource(final EObject object, final String... messages) { + List messageList = Arrays.asList(messages); + final EList errors = object.eResource().getErrors(); + for (String errorMessage : Lists.transform(errors, Resource.Diagnostic::getMessage)) { + assertFalse(messageList.contains(errorMessage), NO_ERRORS_FOUND_ON_RESOURCE_MESSAGE + " with message '" + errorMessage + "'."); + } + } + + /** + * Assert no linking errors on resource with the given message exist. + * + * @param object + * the object + * @param referenceType + * the type of the referenced elements + * @param referenceNames + * the names of the referenced elements + */ + public static void assertNoLinkingErrorsOnResource(final EObject object, final String referenceType, final String... referenceNames) { + final List linkingErrors = object.eResource().getErrors().stream().filter(error -> error instanceof XtextLinkingDiagnostic).collect(Collectors.toList()); + final List errorMessages = Lists.transform(linkingErrors, Resource.Diagnostic::getMessage); + for (final String referenceName : referenceNames) { + boolean found = false; + for (final String errMessage : errorMessages) { + if (errMessage.startsWith(referenceName)) { + found = true; + break; + } + } + assertFalse(found, NLS.bind("Expecting no linking errors on resource for \"{0}\".", referenceName)); + } + } + + /** + * Assert linking errors on resource with the given message exist. + * + * @param object + * the object + * @param referenceType + * the type of the referenced elements + * @param referenceNames + * the names of the referenced elements + */ + public static void assertLinkingErrorsOnResourceExist(final EObject object, final String referenceType, final String... referenceNames) { + final List linkingErrors = object.eResource().getErrors().stream().filter(error -> error instanceof XtextLinkingDiagnostic).collect(Collectors.toList()); + final List errorMessages = Lists.transform(linkingErrors, Resource.Diagnostic::getMessage); + for (final String referenceName : referenceNames) { + boolean found = false; + for (final String errMessage : errorMessages) { + if (errMessage.contains(referenceName)) { + found = true; + break; + } + } + assertTrue(found, NLS.bind("Expected linking error on \"{0}\" but could not find it", referenceName)); + } + } + + /** + * Expect the given linking error messages on the resource of the given model. + * + * @param object + * the object, must not be {@code null} + * @param errorStrings + * the expected linking error error messages, must not be {@code null} + */ + public static void assertLinkingErrorsWithCustomMessageOnResourceExist(final EObject object, final String... errorStrings) { + final List linkingErrors = object.eResource().getErrors().stream().filter(error -> error instanceof XtextLinkingDiagnostic).collect(Collectors.toList()); + final List errorMessages = Lists.transform(linkingErrors, Resource.Diagnostic::getMessage); + for (final String s : errorStrings) { + assertTrue(errorMessages.contains(s), NLS.bind("Expected linking error \"{0}\" but could not find it", s)); + } + } + + /** + * Assert no linking errors on resource with the given message exist. + * + * @param object + * the object, must not be {@code null} + * @param messages + * the linking error messages, must not be {@code null} + */ + public static void assertNoLinkingErrorsWithCustomMessageOnResource(final EObject object, final String... messages) { + List messageList = Arrays.asList(messages); + final List linkingErrors = object.eResource().getErrors().stream().filter(error -> error instanceof XtextLinkingDiagnostic).collect(Collectors.toList()); + for (String errorMessage : Lists.transform(linkingErrors, Resource.Diagnostic::getMessage)) { + assertFalse(messageList.contains(errorMessage), NLS.bind("Expecting no linking errors on resource with message \"{0}\".", errorMessage)); + } + } + + /** + * Expect given error messages on the resource of given model. + * + * @param object + * the object + * @param errorStrings + * the error strings + */ + public static void assertErrorsOnResourceExist(final EObject object, final String... errorStrings) { + final EList errors = object.eResource().getErrors(); + final List errorMessages = Lists.transform(errors, Resource.Diagnostic::getMessage); + for (final String s : errorStrings) { + assertTrue(errorMessages.contains(s), NLS.bind("Expected error \"{0}\" but could not find it", s)); + } + } + + /** + * Validates if there is a syntax error present in the source content. + * + * @param sourceFileName + * the file name that should be associated with the parsed content, must not be {@code null} + * @param sourceContent + * source, must not be {@code null} + */ + protected void assertNoSyntaxErrorsOnResource(final String sourceFileName, final CharSequence sourceContent) { + final XtextTestSource testSource = createTestSource(sourceFileName, sourceContent.toString()); + final List errors = testSource.getModel().eResource().getErrors().stream().filter(error -> error instanceof XtextSyntaxDiagnostic).collect(Collectors.toList()); + if (!errors.isEmpty()) { + StringBuilder sb = new StringBuilder("Syntax error is present in the test source.\nList of all found syntax errors:"); + errors.forEach(err -> sb.append("\n\t ").append(err.getMessage())); + throw new AssertionError(sb.toString()); + } + } + + /** + * Memorize the position and issue code of each resource error that appears in the file. + * + * @param root + * root node of the model to be analyzed + */ + protected void memorizeUnexpectedResourceErrors() { + for (Resource.Diagnostic diagnostic : getUnexpectedResourceDiagnostics()) { + if (diagnostic instanceof AbstractDiagnostic) { + AbstractDiagnostic diag = (AbstractDiagnostic) diagnostic; + // Create error message + StringBuilder sb = new StringBuilder(35); + sb.append("Unexpected diagnostic found. Code '"); + sb.append(diag.getCode()); + sb.append(DOT_AND_LINEBREAK); + // Retrieve the position and add the error + memorizeErrorOnPosition(diag.getOffset(), sb.toString()); + } else { + // Create error message + StringBuilder sb = new StringBuilder(30); + sb.append("Unexpected diagnostic found. '"); + sb.append(diagnostic.toString()); + sb.append(DOT_AND_LINEBREAK); + // Add error message + memorizeErrorOnPosition(0, sb.toString()); + } + } + } + + /** + * Memorize the position and issue code of each unexpected diagnostic that appears in the file. + * A diagnostic is considered as expected if a marker with the issue code in the test file was set. + */ + protected void memorizeUnexpectedErrors() { + for (Diagnostic diagnostic : getUnexpectedDiagnostics()) { + if (diagnostic instanceof AbstractValidationDiagnostic) { + AbstractValidationDiagnostic avd = (AbstractValidationDiagnostic) diagnostic; + // Create error message + StringBuilder sb = new StringBuilder(30); + sb.append("Unexpected issue found. Code '"); + sb.append(avd.getIssueCode()); + sb.append(DOT_AND_LINEBREAK); + // Retrieve the position and add the error + if (avd instanceof FeatureBasedDiagnostic && ((FeatureBasedDiagnostic) avd).getFeature() != null) { + List nodes = NodeModelUtils.findNodesForFeature(avd.getSourceEObject(), ((FeatureBasedDiagnostic) avd).getFeature()); + if (nodes.isEmpty()) { + INode node = NodeModelUtils.getNode(avd.getSourceEObject()); + memorizeErrorOnPosition(getXtextTestUtil().findFirstNonHiddenLeafNode(node).getTotalOffset(), sb.toString()); + } else { + for (INode node : nodes) { + memorizeErrorOnPosition(getXtextTestUtil().findFirstNonHiddenLeafNode(node).getTotalOffset(), sb.toString()); + } + } + } else if (avd instanceof RangeBasedDiagnostic) { + memorizeErrorOnPosition(((RangeBasedDiagnostic) avd).getOffset(), sb.toString()); + } else { + memorizeErrorOnPosition(NodeModelUtils.getNode(avd.getSourceEObject()).getTotalOffset(), sb.toString()); + } + } else { + // Create error message + StringBuilder sb = new StringBuilder(30); + sb.append("Unexpected diagnostic found. '"); + sb.append(diagnostic.toString()); + sb.append(DOT_AND_LINEBREAK); + // Add error message + memorizeErrorOnPosition(0, sb.toString()); + } + } + } + + /** + * Strictly validates a source given by a file name and content. + * + * @param sourceFileName + * the file name that should be associated with the parsed content, must not be {@code null} + * @param sourceType + * defines if the source is a kernel or customer source, must not be {@code null} + * @param sourceContent + * source, must not be {@code null} + */ + protected void validateStrictly(final String sourceFileName, final TestSourceType sourceType, final CharSequence sourceContent) { + XtextTestSource testSource = processMarkers(sourceFileName, sourceType, sourceContent); + memorizeUnexpectedErrors(); + memorizeUnexpectedResourceErrors(); + processErrorsFound(testSource.getContent()); + afterValidate(); + } + + /** + * Strictly validate a kernel source given by a {@link Pair} of file name and content. + * All not expected diagnostics are considered as an error. + * + * @param sourceFileNameAndContent + * the file name and content, given as the key and value of the pair, respectively, must not be {@code null} + */ + protected void validateKernelSourceStrictly(final Pair sourceFileNameAndContent) { + validateKernelSourceStrictly(sourceFileNameAndContent.getKey(), sourceFileNameAndContent.getValue()); + } + + /** + * Strictly validate a customer source given by a {@link Pair} of file name and content. + * All not expected diagnostics are considered as an error. + * + * @param sourceFileNameAndContent + * the file name and content, given as the key and value of the pair, respectively, must not be {@code null} + */ + protected void validateCustomerSourceStrictly(final Pair sourceFileNameAndContent) { + validateCustomerSourceStrictly(sourceFileNameAndContent.getKey(), sourceFileNameAndContent.getValue()); + } + + /** + * Strictly validate a kernel source given by a file name and content. + * All not expected diagnostics are considered as an error. + * + * @param sourceFileName + * the file name that should be associated with the parsed content, must not be {@code null} + * @param sourceContent + * source, must not be {@code null} + */ + protected void validateKernelSourceStrictly(final String sourceFileName, final CharSequence sourceContent) { + validateStrictly(sourceFileName, TestSourceType.CLIENT_ALL, sourceContent); + } + + /** + * Strictly validate a customer source given by a file name and content. + * All not expected diagnostics are considered as an error. + * + * @param sourceFileName + * the file name that should be associated with the parsed content, must not be {@code null} + * @param sourceContent + * source, must not be {@code null} + */ + protected void validateCustomerSourceStrictly(final String sourceFileName, final CharSequence sourceContent) { + validateStrictly(sourceFileName, TestSourceType.CLIENT_CUSTOMER, sourceContent); + } +} diff --git a/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractXtextMarkerBasedTest.java b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractXtextMarkerBasedTest.java new file mode 100644 index 0000000000..fd3febe873 --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.test.core/src/com/avaloq/tools/ddk/xtext/test/jupiter/AbstractXtextMarkerBasedTest.java @@ -0,0 +1,656 @@ +/******************************************************************************* + * Copyright (c) 2025 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.xtext.test.jupiter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.SortedMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.emf.ecore.EObject; +import org.eclipse.xtext.CrossReference; +import org.eclipse.xtext.nodemodel.INode; +import org.eclipse.xtext.nodemodel.util.NodeModelUtils; +import org.eclipse.xtext.xbase.lib.Procedures; + +import com.avaloq.tools.ddk.xtext.test.TagCompilationParticipant; +import com.avaloq.tools.ddk.xtext.test.XtextTestSource; +import com.google.common.collect.LinkedHashMultimap; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Multimap; +import com.google.common.collect.Sets; + + +/** + * Abstract class that supports Xtend based test implementations to use markers in the test sources. + */ +@SuppressWarnings("nls") +public abstract class AbstractXtextMarkerBasedTest extends AbstractXtextTest { + + private static final String INVALID_TEST_CONFIGURATION = "Invalid test configuration. Missing org.eclipse.xtend.lib in MANIFEST.MF in this plugin?"; //$NON-NLS-1$ + private static final String LINE_BREAK = "\n"; + private static final String MARKER_START_GUARD = "##"; + private static final String MARKER_END_GUARD = "#"; + private static final String SPLITTING_LINE = "-------------------------------------------------\n"; + protected static final Pattern PATTERN = Pattern.compile(MARKER_START_GUARD + "(\\d+)" + MARKER_END_GUARD); + + /** The tag id. */ + private int localMarkerIdCounter; + + private final Map assertions = Maps.newHashMap(); + private final SortedMap errorsOnPosition = Maps.newTreeMap(); + /** Used Tags to find Duplicates. */ + private final Set usedTags = Sets.newHashSet(); + + /** + * Indicates if a testing source is a kernel or customer source. + */ + protected enum TestSourceType { + CLIENT_ALL, + CLIENT_CUSTOMER + } + + // -------------------------------------------------------------------------- + // AbstractModelAssertion + // -------------------------------------------------------------------------- + + /** + * Interface for testing assertions on a given source position. + */ + protected abstract class AbstractModelAssertion implements Procedures.Procedure2 { + + @Override + public abstract void apply(EObject semanticModel, Integer pos); + + } + + // -------------------------------------------------------------------------- + // Methods of testing framework + // -------------------------------------------------------------------------- + + @Override + protected void afterEachTest() { + getMarkerTagsInfo().clearTags(localMarkerIdCounter); + super.afterEachTest(); + assertions.clear(); + usedTags.clear(); + errorsOnPosition.clear(); + } + + @Override + protected void beforeEachTest() { + localMarkerIdCounter = 0; + super.beforeEachTest(); + assertFalse(getMarkerTagsInfo().isInvalidTestClass(), INVALID_TEST_CONFIGURATION); + } + + // -------------------------------------------------------------------------- + // Methods to be used by the actual testing classes + // -------------------------------------------------------------------------- + + /** + * {@inheritDoc} + */ + @Override + protected void addKernelSourceToWorkspace(final String sourceFileName, final CharSequence sourceContent) { + String processedContent = processContentAndRegisterOffsets(sourceFileName, sourceContent); + super.addKernelSourceToWorkspace(sourceFileName, processedContent); + } + + /** + * {@inheritDoc} + */ + @Override + protected void addCustomerSourceToWorkspace(final String sourceFileName, final CharSequence sourceContent) { + String processedContent = processContentAndRegisterOffsets(CUSTOMER_SOURCE_PREFIX + sourceFileName, sourceContent); + super.addCustomerSourceToWorkspace(sourceFileName, processedContent); + } + + /** + * Removes tags and stores them in the test info object. + * + * @param sourceFileName + * Source file name, must not be {@code null} + * @param sourceContent + * Content of the test source (may contain tags), must not be {@code null} + * @return Content without tags, never {@code null} + */ + private String processContentAndRegisterOffsets(final String sourceFileName, final CharSequence sourceContent) { + Map offsets = Maps.newHashMap(); + String content = removeMarkersFromContent(sourceContent, offsets); + for (Entry tag : offsets.entrySet()) { + getMarkerTagsInfo().registerRequiredSourceTag(tag.getKey(), sourceFileName, tag.getValue()); + } + return content; + } + + /** + * Removes the Xtend markers from a source. + * + * @param sourceContent + * the source content, not {@code null} + * @param tagToOffset + * Map to be populated with tag to offset pairs, must not be {@code null} + * @return the content without markers, never {@code null} + */ + private String removeMarkersFromContent(final CharSequence sourceContent, final Map tagToOffset) { + StringBuffer withoutMarkers = new StringBuffer(sourceContent.length()); + Matcher m = PATTERN.matcher(sourceContent); + while (m.find()) { + m.appendReplacement(withoutMarkers, ""); + tagToOffset.put(Integer.parseInt(m.group(1)), withoutMarkers.length()); + } + m.appendTail(withoutMarkers); + return withoutMarkers.toString(); + } + + /** + * Returns the model for the given source name and string. + * + * @param sourceFileName + * the file name that should be associated with the parsed content, must not be {@code null} + * @param sourceContent + * source, must not be {@code null} + * @return Model element for the parsed source, may be {@code null} + */ + protected EObject getModel(final String sourceFileName, final CharSequence sourceContent) { + Map offsets = Maps.newHashMap(); + String content = removeMarkersFromContent(sourceContent, offsets); + EObject root = null; + try { + root = getXtextTestUtil().getModel(sourceFileName, content); + INode node = NodeModelUtils.getNode(root); + for (Entry tag : offsets.entrySet()) { + INode leafNode = NodeModelUtils.findLeafNodeAtOffset(node, tag.getValue() + 1); + EObject context = NodeModelUtils.findActualSemanticObjectFor(leafNode); + // Search for cross reference + CrossReference crossReference = null; + while (leafNode != null) { + if (leafNode.getGrammarElement() instanceof CrossReference) { + crossReference = (CrossReference) leafNode.getGrammarElement(); + break; + } + leafNode = leafNode.getParent(); + } + getMarkerTagsInfo().registerLocalTag(tag.getKey(), context, crossReference); + } + } catch (IOException e) { + fail("Exception while creating model from input string: " + e.getMessage()); //$NON-NLS-1$ + } + return root; + } + + /** + * Does the same as get model, but returns void. + * + * @param sourceFileName + * the file name that should be associated with the parsed content + * @param sourceContent + * source + */ + protected void registerModel(final String sourceFileName, final CharSequence sourceContent) { + getModel(sourceFileName, sourceContent); + } + + /** + * Validate a kernel source given by a file name and content. + * All not expected diagnostics are ignored. + * + * @param sourceFileName + * the file name that should be associated with the parsed content, not {@code null} + * @param sourceContent + * source, not {@code null} + */ + protected void validateKernelSource(final String sourceFileName, final CharSequence sourceContent) { + validate(sourceFileName, TestSourceType.CLIENT_ALL, sourceContent); + } + + /** + * Validate a customer source given by a file name and content. + * + * @param sourceFileName + * the file name that should be associated with the parsed content, not {@code null} + * @param sourceContent + * source, not {@code null} + */ + protected void validateCustomerSource(final String sourceFileName, final CharSequence sourceContent) { + validate(sourceFileName, TestSourceType.CLIENT_CUSTOMER, sourceContent); + } + + // -------------------------------------------------------------------------- + // Methods to be used by more specific abstract classes + // -------------------------------------------------------------------------- + + /** + * Add assertion to the list of assertions and return the corresponding string marker. + * + * @param assertion + * assertion to be added, not {@code null} + * @return + * string marker corresponding to the added assertion + */ + protected String addAssertion(final AbstractModelAssertion assertion) { + // Assertions are given a local tag as they are local markers affecting and declared in only one source + Integer markerId = getTag(); + assertions.put(markerId, assertion); + return mark(markerId); + } + + /** + * Creates a mark with the given id. Use this method in tests to insert marks. + *

+ * If the given mark id is 0 then it means that the test was not well configured. All the id-s that are generated by {@link #getTag} start with 1, all the + * global ids start with {@value com.avaloq.tools.asmd.testbase.scoping.TagCompilationParticipant#COUNTER_BASE}. Since it is unlikely to get local ids wrong, + * the most common reason for global ids to be wrong is that they were not initialized. Global ids get initialized with active annotation + * {@link com.avaloq.tools.asmd.testbase.scoping.Tag} that executes {@link com.avaloq.tools.asmd.testbase.scoping.TagCompilationParticipant}. Active + * annotations do not get executed if {@code org.eclipse.xtend.lib} is missing in {@code MANIFEST.MF}. Thus we report this common mistake to the user via an + * assertion. + *

+ * + * @param id + * Mark id + * @return Mark text to be inserted in the source file, never {@code null} + */ + protected String mark(final int id) { + assertFalse(usedTags.contains(id), "Tag with " + id + " used to mark more than one location."); //$NON-NLS-1$ //$NON-NLS-2$ + usedTags.add(id); + if (id < 1) { + getMarkerTagsInfo().setTestClassInvalid(); + throw new AssertionError(INVALID_TEST_CONFIGURATION); + } + return MARKER_START_GUARD + id + MARKER_END_GUARD; + } + + /** + * Memorize an error that was detected during the validation of the current testing file. + * + * @param position + * position of the error in file, not {@code null} + * @param error + * string with error, not {@code null} + */ + protected void memorizeErrorOnPosition(final Integer position, final String error) { + if (!errorsOnPosition.containsKey(position)) { + errorsOnPosition.put(position, new StringBuilder()); + } + errorsOnPosition.get(position).append(error); + } + + /** + * Processes all the inserted markers in a given source and creates a new {@link XtextTestSource} without the markers. + * + * @param sourceFileName + * the name of the source, may be {@code null} + * @param sourceType + * the type of the source, may be {@code null} + * @param sourceContent + * the content of the source, must not be {@code null} + * @return the {@link XtextTestSource} created. + */ + protected XtextTestSource processMarkers(final String sourceFileName, final TestSourceType sourceType, final CharSequence sourceContent) { + StringBuilder withoutMarkers = new StringBuilder(); + final Multimap positionToAssertionMap = LinkedHashMultimap.create(); + Matcher m = PATTERN.matcher(sourceContent); + int lastEnd = 0; + while (m.find()) { + withoutMarkers.append(sourceContent.subSequence(lastEnd, m.start())); + lastEnd = m.end(); + int markerId = Integer.parseInt(m.group(1)); + // save the position of the marker only if we are dealing with an assertion marker + AbstractModelAssertion assertionMarker = assertions.get(markerId); + if (assertionMarker != null) { + positionToAssertionMap.put(withoutMarkers.length(), assertionMarker); + } + } + // Add the rest part of input string + withoutMarkers.append(sourceContent.subSequence(lastEnd, sourceContent.length())); + + // Calculate source name + String fullSourceFileName = ""; + if (sourceType == TestSourceType.CLIENT_CUSTOMER) { + fullSourceFileName = CUSTOMER_SOURCE_PREFIX; + } + fullSourceFileName = fullSourceFileName.concat(sourceFileName); + + EObject semanticModel; + String withoutMarkersAsString = withoutMarkers.toString(); + XtextTestSource testSource = createTestSource(fullSourceFileName, withoutMarkersAsString); + semanticModel = testSource.getModel(); + beforeApplyAssertions(testSource); + // Run validations on markers + for (Map.Entry entry : positionToAssertionMap.entries()) { + entry.getValue().apply(semanticModel, entry.getKey()); + } + return testSource; + } + + /** + * Validate a source given by a file name and content. + * + * @param sourceFileName + * the file name that should be associated with the parsed content, not {@code null} + * @param sourceType + * defines if the source is a kernel or customer source, not {@code null} + * @param sourceContent + * source, not {@code null} + */ + protected void validate(final String sourceFileName, final TestSourceType sourceType, final CharSequence sourceContent) { + XtextTestSource testSource = processMarkers(sourceFileName, sourceType, sourceContent); + processErrorsFound(testSource.getContent()); + afterValidate(); + } + + /** + * Processes all the diagnostics in a given source. + * + * @param sourceWithoutMarkers + * the source to process, must not be {@code null} + */ + protected void processErrorsFound(final String sourceWithoutMarkers) { + if (!errorsOnPosition.isEmpty()) { + // CHECKSTYLE:OFF MagicNumber + StringBuilder sb = new StringBuilder(50); + // CHECKSTYLE:ON + sb.append(memorizedErrorsToString(sourceWithoutMarkers)); + sb.append(SPLITTING_LINE); + sb.append("List of all found diagnostics:\n"); + sb.append(getAdditionalErrorMessageInformation()); + assertEquals("Errors found. Consider compare view.", sourceWithoutMarkers, sb.toString()); + } + } + + /** + * Inject memorized errors into the input file on positions where they were detected. + * + * @param source + * text of the input testing source, not {@code null} + * @return + * input testing source with injected errors, never {@code null} + */ + private String memorizedErrorsToString(final String source) { + StringBuilder result = new StringBuilder(); + StringBuilder errorBuffer = new StringBuilder(); + // Sort positions + List positions = Lists.newArrayList(errorsOnPosition.keySet()); + + int posIdx = 0; + int sourceIdx = 0; + + while (sourceIdx < source.length()) { + int lineBreakIndex = source.indexOf('\n', sourceIdx); + if (lineBreakIndex < 0) { + lineBreakIndex = source.length(); + } + while (posIdx < positions.size() && positions.get(posIdx) < lineBreakIndex) { + int nextPos = positions.get(posIdx); + result.append(source.substring(sourceIdx, nextPos)); + result.append("'); + // Add error message to buffer + errorBuffer.append(SPLITTING_LINE); + errorBuffer.append("FAILURE "); + errorBuffer.append(posIdx + 1); + errorBuffer.append(": "); + errorBuffer.append(errorsOnPosition.get(nextPos)); + sourceIdx = nextPos; + posIdx++; + } + if (errorBuffer.length() > 0) { + errorBuffer.append(SPLITTING_LINE); + } + result.append(source.substring(sourceIdx, lineBreakIndex)); + result.append(errorBuffer); + errorBuffer = new StringBuilder(); + result.append(LINE_BREAK); + sourceIdx = lineBreakIndex + 1; + } + result.append(source.substring(sourceIdx)); + result.append(errorBuffer); + return result.toString(); + } + + /** + * Searches an object for the given tag. First checks local tags. If not found then searches this tag in the required sources. + * + * @param tag + * Tag + * @return EObject or {@code null} + */ + protected EObject getObjectForTag(final int tag) { + EObject object = getMarkerTagsInfo().getModel(tag); + if (object == null) { + // Not in source under test + String sourceName = getMarkerTagsInfo().getSource(tag); + if (sourceName != null) { + INode node = NodeModelUtils.findActualNodeFor(getTestSource(sourceName).getModel()); + INode leafNode = NodeModelUtils.findLeafNodeAtOffset(node, getMarkerTagsInfo().getOffset(tag)); + object = NodeModelUtils.findActualSemanticObjectFor(leafNode); + } + } + assertNotNull(object, "Tag " + tag + " should mark an object. Use «mark(TAG)» in a code snippet."); //$NON-NLS-1$//$NON-NLS-2$ + return object; + } + + /** + * Return the offset for the given tag. + * + * @param tag + * The tag for which to find the offset + * @return the offset found or {@code null} if the given tag is not marking an object + */ + protected Integer getOffsetForTag(final int tag) { + Integer offset = getMarkerTagsInfo().getOffset(tag); + assertNotNull(offset, "Tag " + tag + " should mark an object. Use «mark(TAG)» in a code snippet."); //$NON-NLS-1$//$NON-NLS-2$ + return offset; + } + + /** + * Generate a unique tag id. + *

+ * Use this method for local tags only. Global tags must use @Tag annotation to ensure the same value over multiple instances of the test class. + *

+ * + * @return Tag id + */ + public int getTag() { + localMarkerIdCounter++; + assertTrue(localMarkerIdCounter < TagCompilationParticipant.COUNTER_BASE, "Too many local tags. Must be less than " + TagCompilationParticipant.COUNTER_BASE //$NON-NLS-1$ + + " per test method"); //$NON-NLS-1$ + return localMarkerIdCounter; + } + + /** + * Return the {@link MarkerTagsInfo} associated to one test class. + * + * @return the associated {@link MarkerTagsInfo} + */ + protected MarkerTagsInfo getMarkerTagsInfo() { + MarkerTagsInfo info = (MarkerTagsInfo) getTestInformation().getTestObject(MarkerTagsInfo.class); + if (info == null) { + info = new MarkerTagsInfo(); + getTestInformation().putTestObject(MarkerTagsInfo.class, info); + } + return info; + } + + /** + * This class preserves information about tags in the required sources + * for all tests within one test class. Tags for current test are also stored here. + * One may prefer in the future to be able to clean tags for the current test after the test. + */ + protected class MarkerTagsInfo { + + // For sources under test + /** The tag to model. */ + private final Map tagToModel = Maps.newHashMap(); + + /** The tag to cross reference. */ + private final Map tagToCrossReference = Maps.newHashMap(); + + // For sources added early (only object referencing, no need to search cross references) + /** The tag to source. */ + private final Map tagToSource = Maps.newHashMap(); + + /** The tag to offset. */ + private final Map tagToOffset = Maps.newHashMap(); + + private boolean invalidTestClass; + + /** + * Registers one tag for the source under test. + * This source is actually used for testing and we need both: outgoing cross references as well as declared elements. + * + * @param tag + * New id for the tag + * @param context + * Current object that will correspond to this tag + * @param crossReference + * the cross reference {@code null} for declaration or the corresponding cross reference in the grammar for a reference + */ + public void registerLocalTag(final int tag, final EObject context, final CrossReference crossReference) { + tagToModel.put(tag, context); + if (crossReference != null) { + tagToCrossReference.put(tag, crossReference); + } + } + + /** + * Register a tag in the required source. Only declarations are supported. + * + * @param tag + * New tag (must be unique) + * @param sourceName + * Source name + * @param offset + * Offset within the source + */ + public void registerRequiredSourceTag(final int tag, final String sourceName, final int offset) { + tagToSource.put(tag, sourceName); + tagToOffset.put(tag, offset); + } + + /** + * Returns the context model element for local tags. + * + * @param tag + * Tag + * @return Model element + */ + public EObject getModel(final int tag) { + return tagToModel.get(tag); + } + + /** + * Returns cross references for local tags. + * + * @param tag + * Tag + * @return Cross reference grammar element + */ + public CrossReference getCrossReference(final int tag) { + return tagToCrossReference.get(tag); + } + + /** + * Returns source name for global tag. + * + * @param tag + * Tag + * @return Source name + */ + public String getSource(final int tag) { + return tagToSource.get(tag); + } + + /** + * Returns offsets for global tags. + * + * @param tag + * Tag + * @return Offset + */ + public Integer getOffset(final int tag) { + return tagToOffset.get(tag); + } + + /** + * Clear local tags from current test. Clears all tags up to the id passed, but not outside the range reserved for local tags. + * + * @param maxId + * the tag id + */ + public void clearTags(final long maxId) { + for (int i = 1; i <= Math.min(maxId, TagCompilationParticipant.COUNTER_BASE - 1); i++) { + tagToCrossReference.remove(i); + tagToModel.remove(i); + tagToOffset.remove(i); + tagToSource.remove(i); + } + } + + public boolean isInvalidTestClass() { + return invalidTestClass; + } + + /** + * Marks the current test class as invalid. All tests within this class will report same error. + */ + public void setTestClassInvalid() { + this.invalidTestClass = true; + } + } + + /** + * Before apply assertions. + * + * @param testSource + * the test source, not {@code null} + */ + protected void beforeApplyAssertions(final XtextTestSource testSource) { + } + + /** + * Gets additional error message information. + * + * @return additional error message information, never {@code null} + */ + protected String getAdditionalErrorMessageInformation() { + return ""; + } + + /** + * Returns an unmodifiable view of the tags generated by {@link getTag()}. + * + * @return the unmodifiable view of the {@link #usedTags} set + */ + public Set getUsedTagsItems() { + return Collections.unmodifiableSet(usedTags); + } + + /** + * Processing after validations. + */ + protected void afterValidate() { + } +}