001/*
002 * Copyright 2023 the original author or authors.
003 * <p>
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 * <p>
008 * https://www.apache.org/licenses/LICENSE-2.0
009 * <p>
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package de.cuioss.test.valueobjects.contract;
017
018import static java.util.Objects.requireNonNull;
019import static org.junit.jupiter.api.Assertions.assertEquals;
020import static org.junit.jupiter.api.Assertions.assertFalse;
021import static org.junit.jupiter.api.Assertions.assertNotEquals;
022import static org.junit.jupiter.api.Assertions.assertTrue;
023
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.Collection;
027import java.util.Collections;
028import java.util.HashMap;
029import java.util.List;
030import java.util.Map;
031import java.util.Set;
032import java.util.SortedSet;
033import java.util.TreeSet;
034
035import de.cuioss.test.valueobjects.api.object.ObjectTestConfig;
036import de.cuioss.test.valueobjects.api.object.ObjectTestContract;
037import de.cuioss.test.valueobjects.objects.ParameterizedInstantiator;
038import de.cuioss.test.valueobjects.objects.RuntimeProperties;
039import de.cuioss.test.valueobjects.property.PropertySupport;
040import de.cuioss.tools.logging.CuiLogger;
041import de.cuioss.tools.property.PropertyMemberInfo;
042
043/**
044 * Helper class providing base functionality for test for the
045 * {@link Object#equals(Object)} and {@link Object#hashCode()} variants of
046 * classes.
047 *
048 * @author Oliver Wolff
049 */
050public class EqualsAndHashcodeContractImpl implements ObjectTestContract {
051
052    private static final Integer DEFAULT_INT_VALUE = 0;
053
054    private static final CuiLogger log = new CuiLogger(EqualsAndHashcodeContractImpl.class);
055
056    @Override
057    public void assertContract(final ParameterizedInstantiator<?> instantiator,
058            final ObjectTestConfig objectTestConfig) {
059
060        requireNonNull(instantiator, "parameterizedInstantiator must not be null");
061
062        final var builder = new StringBuilder("Verifying ");
063        builder.append(getClass().getName()).append("\nWith configuration: ").append(instantiator.toString());
064        log.info(builder.toString());
065
066        final Object target = instantiator.newInstanceMinimal();
067        assertBasicContractOnEquals(target);
068        ReflectionUtil.assertHashCodeMethodIsOverriden(target.getClass());
069        assertBasicContractOnHashCode(target);
070
071        if (shouldTestPropertyContract(objectTestConfig)) {
072            executePropertyTests(instantiator, objectTestConfig);
073        } else {
074            log.info("Only checking basic contract of equals() and hasCode()");
075        }
076
077    }
078
079    private static void executePropertyTests(final ParameterizedInstantiator<?> instantiator,
080            final ObjectTestConfig objectTestConfig) {
081        final SortedSet<String> consideredAttributes = new TreeSet<>();
082        instantiator.getRuntimeProperties().getWritableProperties().stream()
083                .filter(p -> PropertyMemberInfo.DEFAULT.equals(p.getPropertyMemberInfo()))
084                .forEach(p -> consideredAttributes.add(p.getName()));
085
086        if (null != objectTestConfig) {
087            // Whitelist takes precedence
088            if (objectTestConfig.equalsAndHashCodeOf().length > 0) {
089                consideredAttributes.clear();
090                consideredAttributes.addAll(Arrays.asList(objectTestConfig.equalsAndHashCodeOf()));
091            } else {
092                consideredAttributes.removeAll(Arrays.asList(objectTestConfig.equalsAndHashCodeExclude()));
093            }
094        }
095        if (consideredAttributes.isEmpty()) {
096            log.debug("No configured properties to be tested. Is this intentional?");
097        } else {
098            log.info("Configured attributes found for equalsAndHashCode-testing: " + consideredAttributes);
099            assertEqualsAndHashCodeWithVariants(instantiator, consideredAttributes);
100        }
101    }
102
103    private static boolean shouldTestPropertyContract(final ObjectTestConfig objectTestConfig) {
104        return null == objectTestConfig || !objectTestConfig.equalsAndHashCodeBasicOnly();
105    }
106
107    /**
108     * Asserts the {@link Object#equals(Object)} and {@link Object#hashCode()}
109     * contract with variants of data.
110     *
111     * @param instantiator
112     */
113    private static void assertEqualsAndHashCodeWithVariants(final ParameterizedInstantiator<?> instantiator,
114            final SortedSet<String> consideredAttributes) {
115
116        assertEqualsAndHasCodeWithAllPropertiesSet(instantiator, consideredAttributes);
117
118        assertEqualsAndHashCodeWithSkippingProperties(instantiator, consideredAttributes);
119
120        assertEqualsAndHashCodeWithChangingProperties(instantiator, consideredAttributes);
121
122    }
123
124    /**
125     * Assert the methods {@link Object#equals(Object)} and
126     * {@link Object#hashCode()} with all properties set
127     *
128     * @param instantiator
129     * @param consideredAttributes
130     */
131    private static void assertEqualsAndHasCodeWithAllPropertiesSet(final ParameterizedInstantiator<?> instantiator,
132            final SortedSet<String> consideredAttributes) {
133        final Collection<String> actualAttributes = new ArrayList<>(consideredAttributes);
134
135        final Collection<String> requiredNames = RuntimeProperties
136                .extractNames(instantiator.getRuntimeProperties().getRequiredProperties());
137
138        actualAttributes.addAll(requiredNames);
139
140        final var properties = instantiator.getRuntimeProperties().getWritableAsPropertySupport(true, actualAttributes);
141
142        final Object fullObject1 = instantiator.newInstance(properties, false);
143        final Object fullObject2 = instantiator.newInstance(properties, false);
144
145        assertEquals(fullObject1, fullObject2, "Objects should be equal with all properties set");
146
147        assertEquals(fullObject2, fullObject1,
148                "Objects should be equal with all properties set, but are not symmetric");
149
150        assertBasicContractOnHashCode(fullObject1);
151    }
152
153    /**
154     * Assert the methods {@link Object#equals(Object)} and
155     * {@link Object#hashCode()} with the given set of properties and iterates
156     * accordingly the available variants
157     *
158     * @param instantiator
159     * @param consideredAttributes
160     */
161    private static void assertEqualsAndHashCodeWithSkippingProperties(final ParameterizedInstantiator<?> instantiator,
162            final Set<String> consideredAttributes) {
163
164        final var information = instantiator.getRuntimeProperties();
165
166        final var allWritableProperties = information.getWritableAsPropertySupport(true).stream()
167                .filter(prop -> consideredAttributes.contains(prop.getName())).toList();
168
169        final var nonDefaultProperties = allWritableProperties.stream().filter(prop -> !prop.isDefaultValue()).toList();
170
171        final var requiredProperties = nonDefaultProperties.stream().filter(PropertySupport::isRequired).toList();
172
173        final var additionalProperties = nonDefaultProperties.stream().filter(property -> !property.isRequired())
174                .toList();
175
176        final var upperBound = Math.min(nonDefaultProperties.size(), consideredAttributes.size()) - 2;
177        if (additionalProperties.isEmpty()) {
178            log.info("Only required or default properties found, therefore no further testing");
179        } else {
180            final Object minimalObject = instantiator.newInstance(requiredProperties, false);
181            final Object fullObject = instantiator.newInstance(allWritableProperties, false);
182            List<PropertySupport> iteratingProperties = new ArrayList<>(requiredProperties);
183            // Common Order of properties
184            for (final PropertySupport support : additionalProperties) {
185                if (iteratingProperties.size() < upperBound) {
186                    iteratingProperties.add(support);
187                } else {
188                    // Special case for the last property to be set but the objects
189                    // still need to be unequal. For this last property to be iterated the value
190                    // will be set to an explicit unequal value:
191                    iteratingProperties.add(support.createCopyWithNonEqualValue());
192                }
193                final Object iterating = instantiator.newInstance(iteratingProperties, false);
194                final var current = support.getName();
195                assertEqualObjectAreNotEqual(minimalObject, iterating, current);
196                assertEqualObjectAreNotEqual(fullObject, iterating, current);
197                assertBasicContractOnHashCode(iterating);
198            }
199            // reverse Order of additional properties
200            iteratingProperties = new ArrayList<>(requiredProperties);
201            final List<PropertySupport> reverseAddtionalProperties = new ArrayList<>(additionalProperties);
202            Collections.reverse(reverseAddtionalProperties);
203            for (final PropertySupport support : reverseAddtionalProperties) {
204                if (iteratingProperties.size() < upperBound) {
205                    iteratingProperties.add(support);
206                } else {
207                    iteratingProperties.add(support.createCopyWithNonEqualValue());
208                }
209                final Object iterating = instantiator.newInstance(iteratingProperties, false);
210                final var current = support.getName();
211                assertEqualObjectAreNotEqual(minimalObject, iterating, current);
212                assertEqualObjectAreNotEqual(fullObject, iterating, current);
213                assertBasicContractOnHashCode(iterating);
214            }
215        }
216
217    }
218
219    private static void assertEqualsAndHashCodeWithChangingProperties(final ParameterizedInstantiator<?> instantiator,
220            final SortedSet<String> consideredAttributes) {
221        final Map<String, PropertySupport> allWritableProperties = new HashMap<>();
222
223        instantiator.getRuntimeProperties().getWritableAsPropertySupport(true)
224                .forEach(p -> allWritableProperties.put(p.getName(), p));
225
226        final Object expected = instantiator.newInstance(new ArrayList<>(allWritableProperties.values()), false);
227        for (final String name : consideredAttributes) {
228            final Map<String, PropertySupport> current = new HashMap<>(allWritableProperties);
229            assertTrue(current.containsKey(name), "Invalid configuration found: " + name + " not defined as property.");
230            current.put(name, current.get(name).createCopyWithNonEqualValue());
231            final Object actual = instantiator.newInstance(new ArrayList<>(current.values()), false);
232            assertEqualObjectAreNotEqual(expected, actual, name);
233        }
234        // Now reverse order
235        final List<String> reverse = new ArrayList<>(consideredAttributes);
236        Collections.reverse(reverse);
237        for (final String name : reverse) {
238            final Map<String, PropertySupport> current = new HashMap<>(allWritableProperties);
239            assertTrue(current.containsKey(name), "Invalid configuration found: " + name + " not defined as property.");
240            current.put(name, current.get(name).createCopyWithNonEqualValue());
241            final Object actual = instantiator.newInstance(new ArrayList<>(current.values()), false);
242            assertEqualObjectAreNotEqual(expected, actual, name);
243        }
244    }
245
246    private static void assertEqualObjectAreNotEqual(final Object expected, final Object actual,
247            final String deltaPropertyName) {
248        final var message = new StringBuilder("The Objects of type ").append(expected.getClass().getName())
249                .append(" should not be equal, current property=").append(deltaPropertyName).toString();
250        assertNotEquals(expected, actual, message);
251        assertNotEquals(actual, expected, message);
252    }
253
254    /**
255     * Verify object has implemented {@link Object#equals(Object)} method. In
256     * addition it checks whether the basic functionality like
257     * <ul>
258     * <li>equals(null) will be 'false'</li>
259     * <li>equals(new Object()) will be 'false'</li>
260     * <li>underTest.equals(underTest) will be 'true'</li>
261     * </ul>
262     * is implemented correctly
263     *
264     * @param underTest object under test
265     */
266    @SuppressWarnings({ "squid:S2159", "squid:S1764" }) // Sonar complains that the x.equals(x) is
267                                                        // always true. This is
268    // only the case if implemented correctly, what is checked
269    // within this test
270    public static void assertBasicContractOnEquals(final Object underTest) {
271
272        ReflectionUtil.assertEqualsMethodIsOverriden(underTest.getClass());
273
274        // basic checks to equals implementation
275        final var msgNotEqualsNull = "Expected result for equals(null) will be 'false'. Class was : "
276                + underTest.getClass();
277
278        assertFalse(underTest.equals(null), msgNotEqualsNull);
279
280        final var msgNotEqualsObject = "Expected result for equals(new Object()) will be 'false'. Class was : "
281                + underTest.getClass();
282
283        assertFalse(underTest.equals(new Object()), msgNotEqualsObject);
284
285        final var msgEqualsToSelf = "Expected result for equals(underTest) will be 'true'. Class was : "
286                + underTest.getClass();
287
288        assertTrue(underTest.equals(underTest), msgEqualsToSelf);
289
290    }
291
292    /**
293     * Verify object has implemented {@link Object#hashCode()} method.
294     *
295     * @param underTest object under test
296     */
297    public static void assertBasicContractOnHashCode(final Object underTest) {
298
299        // basic checks to hashCode implementation
300        assertNotEquals(DEFAULT_INT_VALUE, underTest.hashCode(),
301                "Expected result of hashCode method is not '0'. Class was : " + underTest.getClass());
302    }
303
304}