diff --git a/CHANGELOG.md b/CHANGELOG.md index 2188a10a0..6043d7195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- AbstractMethodError when a the `equals` method in a field's class calls an abstract method. ([Issue 938](https://github.com/jqno/equalsverifier/issues/938)) + ## [3.16] - 2024-03-22 ### Added diff --git a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/prefabvalues/PrefabValues.java b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/prefabvalues/PrefabValues.java index 799e7fd0d..dde09c903 100644 --- a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/prefabvalues/PrefabValues.java +++ b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/prefabvalues/PrefabValues.java @@ -117,6 +117,7 @@ public T giveOther(TypeTag tag, T value) { * @param typeStack Keeps track of recursion in the type. * @return A value that is different from {@code value}. */ + // CHECKSTYLE OFF: CyclomaticComplexity public T giveOther(TypeTag tag, T value, LinkedHashSet typeStack) { Class type = tag.getType(); if ( @@ -134,12 +135,21 @@ public T giveOther(TypeTag tag, T value, LinkedHashSet typeStack) { if (type.isArray() && arraysAreDeeplyEqual(tuple.getRed(), value)) { return tuple.getBlue(); } - if (!type.isArray() && value != null && tuple.getRed().equals(value)) { - return tuple.getBlue(); + if (!type.isArray() && value != null) { + try { + // red's equals can potentially call an abstract method + if (tuple.getRed().equals(value)) { + return tuple.getBlue(); + } + } catch (AbstractMethodError e) { + return tuple.getRed(); + } } return tuple.getRed(); } + // CHECKSTYLE ON: CyclomaticComplexity + private boolean wraps(Class expectedClass, Class actualClass) { return PrimitiveMappers.PRIMITIVE_OBJECT_MAPPER.get(expectedClass) == actualClass; } diff --git a/equalsverifier-core/src/test/java/nl/jqno/equalsverifier/integration/extended_contract/AbstractDelegationTest.java b/equalsverifier-core/src/test/java/nl/jqno/equalsverifier/integration/extended_contract/AbstractDelegationTest.java index c41e19c8e..b816c5d82 100644 --- a/equalsverifier-core/src/test/java/nl/jqno/equalsverifier/integration/extended_contract/AbstractDelegationTest.java +++ b/equalsverifier-core/src/test/java/nl/jqno/equalsverifier/integration/extended_contract/AbstractDelegationTest.java @@ -205,6 +205,36 @@ public void originalMessageIsIncludedInErrorMessage_whenEqualsVerifierSignalsAnA .assertMessageContains("This is AbstractMethodError's original message"); } + @Test + public void failGracefully_whenAFieldsEqualsMethodDoesntDoAnIdentityCheckButCallsAnAbstractField() { + ExpectedException + .when(() -> + EqualsVerifier + .forClass(EqualsInFieldWithoutIdentityCheckDelegatesToAbstractMethod.class) + .verify() + ) + .assertFailure() + .assertCause(AbstractMethodError.class) + .assertMessageContains( + ABSTRACT_DELEGATION, + EQUALS_DELEGATES, + PREFAB, + AbstractEqualsWithoutIdentityCheckDelegator.class.getSimpleName() + ); + } + + @Test + public void succeed_whenAFieldsEqualsMethodDoesntDoAnIdentityCheckButCallsAnAbstractField_givenAConcretePrefabImplementationOfSaidField() { + EqualsVerifier + .forClass(EqualsInFieldWithoutIdentityCheckDelegatesToAbstractMethod.class) + .withPrefabValues( + AbstractEqualsWithoutIdentityCheckDelegator.class, + new AbstractEqualsWithoutIdentityCheckDelegatorImpl(1), + new AbstractEqualsWithoutIdentityCheckDelegatorImpl(2) + ) + .verify(); + } + private abstract static class AbstractClass { private int i; @@ -291,6 +321,48 @@ public boolean theAnswer() { } } + abstract static class AbstractEqualsWithoutIdentityCheckDelegator { + + private final int i; + + public AbstractEqualsWithoutIdentityCheckDelegator(int i) { + this.i = i; + } + + abstract boolean theAnswer(); + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof AbstractEqualsWithoutIdentityCheckDelegator)) { + return false; + } + if (theAnswer()) { + return true; + } + AbstractEqualsWithoutIdentityCheckDelegator other = + (AbstractEqualsWithoutIdentityCheckDelegator) obj; + return i == other.i; + } + + @Override + public int hashCode() { + return defaultHashCode(this); + } + } + + static final class AbstractEqualsWithoutIdentityCheckDelegatorImpl + extends AbstractEqualsWithoutIdentityCheckDelegator { + + public AbstractEqualsWithoutIdentityCheckDelegatorImpl(int i) { + super(i); + } + + @Override + public boolean theAnswer() { + return false; + } + } + abstract static class AbstractHashCodeDelegator { private final int i; @@ -612,4 +684,30 @@ public int hashCode() { return defaultHashCode(this); } } + + static class EqualsInFieldWithoutIdentityCheckDelegatesToAbstractMethod { + + private final AbstractEqualsWithoutIdentityCheckDelegator id; + + protected EqualsInFieldWithoutIdentityCheckDelegatesToAbstractMethod( + AbstractEqualsWithoutIdentityCheckDelegator id + ) { + this.id = id; + } + + @Override + public final boolean equals(Object other) { + if (!(other instanceof EqualsInFieldWithoutIdentityCheckDelegatesToAbstractMethod)) { + return false; + } + EqualsInFieldWithoutIdentityCheckDelegatesToAbstractMethod that = + (EqualsInFieldWithoutIdentityCheckDelegatesToAbstractMethod) other; + return Objects.equals(id, that.id); + } + + @Override + public final int hashCode() { + return Objects.hash(id); + } + } }