diff --git a/src/main/java/dev/openfeature/sdk/AbstractStructure.java b/src/main/java/dev/openfeature/sdk/AbstractStructure.java index 7962705c3..4d58ded55 100644 --- a/src/main/java/dev/openfeature/sdk/AbstractStructure.java +++ b/src/main/java/dev/openfeature/sdk/AbstractStructure.java @@ -3,10 +3,9 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; -import lombok.EqualsAndHashCode; +import java.util.Objects; @SuppressWarnings({"PMD.BeanMembersShouldSerialize", "checkstyle:MissingJavadocType"}) -@EqualsAndHashCode abstract class AbstractStructure implements Structure { protected final Map attributes; @@ -48,4 +47,18 @@ public Map asObjectMap() { (accumulated, entry) -> accumulated.put(entry.getKey(), convertValue(entry.getValue())), HashMap::putAll); } + + @Override + public boolean equals(Object object) { + if (!(object instanceof AbstractStructure)) { + return false; + } + AbstractStructure that = (AbstractStructure) object; + return Objects.equals(attributes, that.attributes); + } + + @Override + public int hashCode() { + return Objects.hashCode(attributes); + } } diff --git a/src/main/java/dev/openfeature/sdk/EvaluationContext.java b/src/main/java/dev/openfeature/sdk/EvaluationContext.java index 84760c0d9..aeef33f89 100644 --- a/src/main/java/dev/openfeature/sdk/EvaluationContext.java +++ b/src/main/java/dev/openfeature/sdk/EvaluationContext.java @@ -24,6 +24,31 @@ public interface EvaluationContext extends Structure { */ EvaluationContext merge(EvaluationContext overridingContext); + /** + * If the other object is an EvaluationContext, this method compares the results of asUnmodifiableMap() for + * equality. Otherwise, it returns false. + *
+ *
+ * Implementations of EvaluationContext are encouraged to delegate their equals() method to this method, or provide + * a more optimized check with the same semantics. + * + * @param other the object to compare to + * @return true if the other object is an EvaluationContext and has the same map representation, false otherwise + */ + default boolean isEqualTo(Object other) { + if (other == null) { + return false; + } + if (other == this) { + return true; + } + if (!(other instanceof EvaluationContext)) { + return false; + } + var otherContext = (EvaluationContext) other; + return asUnmodifiableMap().equals(otherContext.asUnmodifiableMap()); + } + /** * Recursively merges the overriding map into the base Value map. * The base map is mutated, the overriding map is not. diff --git a/src/main/java/dev/openfeature/sdk/ImmutableContext.java b/src/main/java/dev/openfeature/sdk/ImmutableContext.java index 35f28d4f4..accebe6fc 100644 --- a/src/main/java/dev/openfeature/sdk/ImmutableContext.java +++ b/src/main/java/dev/openfeature/sdk/ImmutableContext.java @@ -5,7 +5,6 @@ import java.util.HashMap; import java.util.Map; import java.util.function.Function; -import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.experimental.Delegate; @@ -17,7 +16,6 @@ * not be modified after instantiation. */ @ToString -@EqualsAndHashCode @SuppressWarnings("PMD.BeanMembersShouldSerialize") public final class ImmutableContext implements EvaluationContext { @@ -26,6 +24,9 @@ public final class ImmutableContext implements EvaluationContext { @Delegate(excludes = DelegateExclusions.class) private final ImmutableStructure structure; + // Lazily computed hash code, safe because this class is immutable. + private volatile Integer cachedHashCode; + /** * Create an immutable context with an empty targeting_key and attributes * provided. @@ -96,6 +97,40 @@ public EvaluationContext merge(EvaluationContext overridingContext) { return new ImmutableContext(attributes); } + /** + * Equality for EvaluationContext implementations is defined in terms of their resolved + * attribute maps. Two contexts are considered equal if their {@link #asMap()} representations + * contain the same key/value pairs, regardless of how the context was constructed or layered. + * + * @param o the object to compare with this context + * @return true if the other object is an EvaluationContext whose resolved attributes match + */ + @Override + public boolean equals(Object o) { + return isEqualTo(o); + } + + /** + * Computes a hash code consistent with {@link #equals(Object)}. Since this context is immutable, + * the hash code is lazily computed once from its resolved attribute map and then cached. + * + * @return the cached hash code derived from this context's attribute map + */ + @Override + public int hashCode() { + Integer result = cachedHashCode; + if (result == null) { + synchronized (this) { + result = cachedHashCode; + if (result == null) { + result = structure.hashCode(); + cachedHashCode = result; + } + } + } + return result; + } + @SuppressWarnings("all") private static class DelegateExclusions { @ExcludeFromGeneratedCoverageReport diff --git a/src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java b/src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java index bdd81f8c3..a58d82685 100644 --- a/src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java +++ b/src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java @@ -21,6 +21,9 @@ public class LayeredEvaluationContext implements EvaluationContext { private ArrayList hookContexts; private String targetingKey; private Set keySet = null; + // Lazily computed resolved attribute map for this layered context. + // This must be invalidated whenever the underlying layers change. + private Map cachedMap; /** * Constructor for LayeredEvaluationContext. @@ -174,15 +177,20 @@ public Value getValue(String key) { return getFromContext(apiContext, key); } - @Override - public Map asMap() { + private Map getResolvedMap() { + if (cachedMap != null) { + return cachedMap; + } + if (keySet != null && keySet.isEmpty()) { - return new HashMap<>(0); + cachedMap = Collections.emptyMap(); + return cachedMap; } HashMap map; if (keySet != null) { - map = new HashMap<>(keySet.size()); + // use helper to size the map based on expected entries + map = HashMapUtils.forEntries(keySet.size()); } else { map = new HashMap<>(); } @@ -205,7 +213,15 @@ public Map asMap() { map.putAll(hookContext.asMap()); } } - return map; + + cachedMap = Collections.unmodifiableMap(map); + return cachedMap; + } + + @Override + public Map asMap() { + // Return a defensive copy so callers can't mutate our cached map. + return new HashMap<>(getResolvedMap()); } @Override @@ -214,7 +230,7 @@ public Map asUnmodifiableMap() { return Collections.emptyMap(); } - return Collections.unmodifiableMap(asMap()); + return getResolvedMap(); } @Override @@ -225,7 +241,8 @@ public Map asObjectMap() { HashMap map; if (keySet != null) { - map = new HashMap<>(keySet.size()); + // use helper to size the map based on expected entries + map = HashMapUtils.forEntries(keySet.size()); } else { map = new HashMap<>(); } @@ -248,9 +265,33 @@ public Map asObjectMap() { map.putAll(hookContext.asObjectMap()); } } + return map; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof EvaluationContext)) { + return false; + } + + EvaluationContext that = (EvaluationContext) o; + + if (that instanceof LayeredEvaluationContext) { + return this.getResolvedMap().equals(((LayeredEvaluationContext) that).getResolvedMap()); + } + + return this.getResolvedMap().equals(that.asUnmodifiableMap()); + } + + @Override + public int hashCode() { + return getResolvedMap().hashCode(); + } + void putHookContext(EvaluationContext context) { if (context == null || context.isEmpty()) { return; @@ -265,5 +306,6 @@ void putHookContext(EvaluationContext context) { } this.hookContexts.add(context); this.keySet = null; + this.cachedMap = null; } } diff --git a/src/main/java/dev/openfeature/sdk/MutableContext.java b/src/main/java/dev/openfeature/sdk/MutableContext.java index 7fda58065..2ad68c98a 100644 --- a/src/main/java/dev/openfeature/sdk/MutableContext.java +++ b/src/main/java/dev/openfeature/sdk/MutableContext.java @@ -6,7 +6,6 @@ import java.util.List; import java.util.Map; import java.util.function.Function; -import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.experimental.Delegate; @@ -17,7 +16,6 @@ * be modified after instantiation. */ @ToString -@EqualsAndHashCode @SuppressWarnings("PMD.BeanMembersShouldSerialize") public class MutableContext implements EvaluationContext { @@ -125,6 +123,24 @@ public EvaluationContext merge(EvaluationContext overridingContext) { return new MutableContext(attributes); } + /** + * Equality for EvaluationContext implementations is defined in terms of their resolved + * attribute maps. Two contexts are considered equal if their {@link #asMap()} representations + * contain the same key/value pairs, regardless of how the context was constructed or layered. + * + * @param o the object to compare with this context + * @return true if the other object is an EvaluationContext whose resolved attributes match + */ + @Override + public boolean equals(Object o) { + return isEqualTo(o); + } + + @Override + public int hashCode() { + return structure.hashCode(); + } + /** * Hidden class to tell Lombok not to copy these methods over via delegation. */ diff --git a/src/test/java/dev/openfeature/sdk/ImmutableContextTest.java b/src/test/java/dev/openfeature/sdk/ImmutableContextTest.java index 0b8a44d0d..2852be536 100644 --- a/src/test/java/dev/openfeature/sdk/ImmutableContextTest.java +++ b/src/test/java/dev/openfeature/sdk/ImmutableContextTest.java @@ -4,12 +4,16 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class ImmutableContextTest { @@ -50,7 +54,7 @@ void shouldChangeTargetingKeyFromOverridingContext() { assertEquals("overriding_key", merge.getTargetingKey()); } - @DisplayName("targeting key should not changed from the overriding context if missing") + @DisplayName("targeting key should not be changed from the overriding context if missing") @Test void shouldRetainTargetingKeyWhenOverridingContextTargetingKeyValueIsEmpty() { HashMap attributes = new HashMap<>(); @@ -66,7 +70,7 @@ void shouldRetainTargetingKeyWhenOverridingContextTargetingKeyValueIsEmpty() { @Test void missingTargetingKeyShould() { EvaluationContext ctx = new ImmutableContext(); - assertEquals(null, ctx.getTargetingKey()); + assertNull(ctx.getTargetingKey()); } @DisplayName("Merge should retain all the attributes from the existing context when overriding context is null") @@ -145,10 +149,26 @@ void mergeShouldObtainKeysFromOverridingContextWhenExistingContextIsEmpty() { EvaluationContext ctx = new ImmutableContext(); EvaluationContext overriding = new ImmutableContext(attributes); EvaluationContext merge = ctx.merge(overriding); - assertEquals(new java.util.HashSet<>(java.util.Arrays.asList("key1", "key2")), merge.keySet()); + assertEquals(new HashSet<>(Arrays.asList("key1", "key2")), merge.keySet()); } - @DisplayName("Two different MutableContext objects with the different contents are not considered equal") + @DisplayName("Two ImmutableContext objects with identical attributes are considered equal") + @Test + void testImmutableContextEquality() { + Map map1 = new HashMap<>(); + map1.put("key", new Value("value")); + + Map map2 = new HashMap<>(); + map2.put("key", new Value("value")); + + ImmutableContext a = new ImmutableContext(null, map1); + ImmutableContext b = new ImmutableContext(null, map2); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @DisplayName("Two different ImmutableContext objects with different contents are not considered equal") @Test void unequalImmutableContextsAreNotEqual() { final Map attributes = new HashMap<>(); @@ -161,17 +181,57 @@ void unequalImmutableContextsAreNotEqual() { assertNotEquals(ctx, ctx2); } - @DisplayName("Two different MutableContext objects with the same content are considered equal") + @DisplayName("ImmutableContext hashCode is stable across multiple invocations") @Test - void equalImmutableContextsAreEqual() { - final Map attributes = new HashMap<>(); - attributes.put("key1", new Value("val1")); - final ImmutableContext ctx = new ImmutableContext(attributes); + void immutableContextHashCodeIsStable() { + Map map = new HashMap<>(); + map.put("key", new Value("value")); - final Map attributes2 = new HashMap<>(); - attributes2.put("key1", new Value("val1")); - final ImmutableContext ctx2 = new ImmutableContext(attributes2); + ImmutableContext ctx = new ImmutableContext(null, map); + + int first = ctx.hashCode(); + int second = ctx.hashCode(); + assertEquals(first, second); + } + + @Nested + class Equals { + ImmutableContext ctx = new ImmutableContext("c", Map.of("a", new Value("b"))); + + @Test + void equalsItself() { + assertEquals(ctx, ctx); + } + + @Test + void equalsLayeredEvalCtxIfSameValues() { + var layeredContext = new LayeredEvaluationContext(ctx, null, null, null); + assertEquals(layeredContext, ctx); + assertEquals(ctx, layeredContext); + } + + @Test + void equalsDifferentMutableEvalCtxIfSameValues() { + var mutable = new MutableContext("c", Map.of("a", new Value("b"))); + assertEquals(mutable, ctx); + assertEquals(ctx, mutable); + } + } - assertEquals(ctx, ctx2); + @Nested + class HashCode { + ImmutableContext ctx = new ImmutableContext("c", Map.of("a", new Value("b"))); + + @Test + void hashCodeEqualsLayeredEvalCtxIfSameValues() { + var layeredContext = new LayeredEvaluationContext(ctx, null, null, null); + assertEquals(layeredContext.hashCode(), ctx.hashCode()); + } + + @Test + void hashCodeEqualsDifferentMutableEvalCtxIfSameValues() { + var mutable = new MutableContext("c", Map.of("a", new Value("b"))); + assertEquals(mutable.hashCode(), ctx.hashCode()); + } } } diff --git a/src/test/java/dev/openfeature/sdk/LayeredEvaluationContextTest.java b/src/test/java/dev/openfeature/sdk/LayeredEvaluationContextTest.java index 7eecd9abd..edbea81d5 100644 --- a/src/test/java/dev/openfeature/sdk/LayeredEvaluationContextTest.java +++ b/src/test/java/dev/openfeature/sdk/LayeredEvaluationContextTest.java @@ -1,6 +1,11 @@ package dev.openfeature.sdk; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Map; import java.util.Set; @@ -257,6 +262,75 @@ void creatingMapWithCachedNonEmptyKeySetWorks() { assertNotNull(layeredContext.keySet()); assertEquals(apiContext.asObjectMap(), layeredContext.asObjectMap()); } + + @Test + void nestedContextsAreUnwrappedCorrectly() { + var innerApiContext = new ImmutableContext(Map.of("inner", new Value("api"))); + var outerApiContext = new ImmutableContext(Map.of("outer", new Value(innerApiContext))); + + var innerClientContext = new ImmutableContext(Map.of("inner", new Value("client"))); + var outerClientContext = new ImmutableContext(Map.of("outer", new Value(innerClientContext))); + var layeredContext = new LayeredEvaluationContext(outerApiContext, null, outerClientContext, null); + + var objectMap = layeredContext.asObjectMap(); + + assertEquals(Map.of("outer", Map.of("inner", "client")), objectMap); + } + + @Test + void nestedStructuresInContextsAreUnwrappedCorrectly() { + var innerApiStructure = new ImmutableStructure(Map.of("inner", new Value("api"))); + var outerApiContext = new ImmutableContext(Map.of("outer", new Value(innerApiStructure))); + + var innerClientStructure = new ImmutableStructure(Map.of("inner", new Value("client"))); + var outerClientContext = new ImmutableContext(Map.of("outer", new Value(innerClientStructure))); + var layeredContext = new LayeredEvaluationContext(outerApiContext, null, outerClientContext, null); + + var objectMap = layeredContext.asObjectMap(); + + assertEquals(Map.of("outer", Map.of("inner", "client")), objectMap); + } + + @Test + void nestedHookContextsAreUnwrappedCorrectly() { + var innerApiStructure = new ImmutableStructure(Map.of("inner", new Value("api"))); + var outerApiContext = new ImmutableContext(Map.of("outer", new Value(innerApiStructure))); + + var innerClientStructure = new ImmutableStructure(Map.of("inner", new Value("client"))); + var outerClientContext = new ImmutableContext(Map.of("outer", new Value(innerClientStructure))); + var layeredContext = new LayeredEvaluationContext(outerApiContext, null, outerClientContext, null); + + var innerHookStructure = new ImmutableStructure(Map.of("inner", new Value("hook"))); + var outerHookContext = new ImmutableContext(Map.of("outer", new Value(innerHookStructure))); + + layeredContext.putHookContext(outerHookContext); + + var objectMap = layeredContext.asObjectMap(); + + assertEquals(Map.of("outer", Map.of("inner", "hook")), objectMap); + } + + @Test + void objectMapIsMutable() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext); + + var objectMap = layeredContext.asObjectMap(); + assertDoesNotThrow(() -> objectMap.put("a", "b")); + assertEquals("b", objectMap.get("a")); + } + + @Test + void mutatingObjectMapHasNoSideEffects() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext); + + var objectMap1 = layeredContext.asObjectMap(); + objectMap1.put("a", "b"); + + var objectMap2 = layeredContext.asObjectMap(); + assertNull(objectMap2.get("a")); + } } @Nested @@ -397,5 +471,94 @@ void mergesCorrectlyWhenOtherHasNoTargetingKey() { merged.asMap()); assertEquals(invocationContext.getTargetingKey(), merged.getTargetingKey()); } + + @Test + void testLayeredContextEquality() { + Map baseMap = Map.of("k", new Value("v")); + Map layerMap = Map.of("x", new Value("y")); + + EvaluationContext base = new MutableContext(null, baseMap); + EvaluationContext layer = new MutableContext(null, layerMap); + + LayeredEvaluationContext l1 = new LayeredEvaluationContext(base, layer, null, null); + LayeredEvaluationContext l2 = new LayeredEvaluationContext(base, layer, null, null); + + assertEquals(l1, l2); + assertEquals(l1.hashCode(), l2.hashCode()); + } + + @Test + void testMixedContextEquality() { + Map map = Map.of("foo", new Value("bar")); + + EvaluationContext base = new MutableContext(null, map); + LayeredEvaluationContext layered = new LayeredEvaluationContext(null, null, null, base); + + // Equality from the layered context's perspective (map-based equality) + assertEquals(layered, base); + + // Resolved maps should be identical + assertEquals(base.asMap(), layered.asMap()); + + // Layered's hashCode must be consistent with its resolved attribute map + assertEquals(base.asMap().hashCode(), layered.hashCode()); + } + } + + @Nested + class Equals { + @Test + void equalsItself() { + var layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext); + layeredContext.putHookContext(hookContext); + assertEquals(layeredContext, layeredContext); + } + + @Test + void equalsDifferentLayeredEvalCtxIfSameValues() { + var layeredContext1 = new LayeredEvaluationContext(apiContext, null, null, null); + var layeredContext2 = new LayeredEvaluationContext(null, apiContext, null, null); + assertEquals(layeredContext1, layeredContext2); + } + + @Test + void equalsDifferentImmutableEvalCtxIfSameValues() { + var immutable = new ImmutableContext("key", Map.of("prop", new Value("erty"))); + var layeredContext = new LayeredEvaluationContext(immutable, null, null, null); + assertEquals(immutable, layeredContext); + assertEquals(layeredContext, immutable); + } + + @Test + void equalsDifferentMutableEvalCtxIfSameValues() { + var mutable = new MutableContext("key", Map.of("prop", new Value("erty"))); + var layeredContext = new LayeredEvaluationContext(mutable, null, null, null); + assertEquals(mutable, layeredContext); + assertEquals(layeredContext, mutable); + } + } + + @Nested + class HashCode { + ImmutableContext immutable = new ImmutableContext("c", Map.of("a", new Value("b"))); + LayeredEvaluationContext layeredContext = new LayeredEvaluationContext(immutable, null, null, null); + + @Test + void hashCodeEqualsItself() { + var layeredContext2 = new LayeredEvaluationContext(null, null, immutable, null); + assertEquals(layeredContext.hashCode(), layeredContext2.hashCode()); + } + + @Test + void hasSameHashCodeAsImmutableEvalCtxIfSameValues() { + assertEquals(immutable.hashCode(), layeredContext.hashCode()); + } + + @Test + void hashCodeEqualsDifferentMutableEvalCtxIfSameValues() { + MutableContext ctx = new MutableContext("c", Map.of("a", new Value("b"))); + assertEquals(immutable.hashCode(), ctx.hashCode()); + } } } diff --git a/src/test/java/dev/openfeature/sdk/MutableContextTest.java b/src/test/java/dev/openfeature/sdk/MutableContextTest.java index 6c471d09a..b81cbccb1 100644 --- a/src/test/java/dev/openfeature/sdk/MutableContextTest.java +++ b/src/test/java/dev/openfeature/sdk/MutableContextTest.java @@ -10,6 +10,7 @@ import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class MutableContextTest { @@ -139,30 +140,71 @@ void shouldAllowChainingOfMutations() { assertEquals(3.0, context.getValue("key3").asDouble()); } - @DisplayName("Two different MutableContext objects with the different contents are not considered equal") - @Test - void unequalMutableContextsAreNotEqual() { - final Map attributes = new HashMap<>(); - attributes.put("key1", new Value("val1")); - final MutableContext ctx = new MutableContext(attributes); - - final Map attributes2 = new HashMap<>(); - final MutableContext ctx2 = new MutableContext(attributes2); - - assertNotEquals(ctx, ctx2); + @Nested + class Equals { + MutableContext ctx = new MutableContext("c", Map.of("a", new Value("b"))); + + @Test + void equalsItself() { + assertEquals(ctx, ctx); + } + + @Test + void equalsLayeredEvalCtxIfSameValues() { + var layeredContext = new LayeredEvaluationContext(ctx, null, null, null); + assertEquals(layeredContext, ctx); + assertEquals(ctx, layeredContext); + } + + @Test + void equalsDifferentMutableEvalCtxIfSameValues() { + var immutable = new ImmutableContext("c", Map.of("a", new Value("b"))); + assertEquals(immutable, ctx); + assertEquals(ctx, immutable); + } + + @DisplayName("Two different MutableContext objects with the different contents are not considered equal") + @Test + void unequalMutableContextsAreNotEqual() { + final Map attributes = new HashMap<>(); + attributes.put("key1", new Value("val1")); + final MutableContext context = new MutableContext(attributes); + + final Map attributes2 = new HashMap<>(); + final MutableContext ctx2 = new MutableContext(attributes2); + + assertNotEquals(context, ctx2); + } + + @DisplayName("Two different MutableContext objects with the same content are considered equal") + @Test + void equalMutableContextsAreEqual() { + final Map attributes = new HashMap<>(); + attributes.put("key1", new Value("val1")); + final MutableContext context = new MutableContext(attributes); + + final Map attributes2 = new HashMap<>(); + attributes2.put("key1", new Value("val1")); + final MutableContext ctx2 = new MutableContext(attributes2); + + assertEquals(context, ctx2); + } } - @DisplayName("Two different MutableContext objects with the same content are considered equal") - @Test - void equalMutableContextsAreEqual() { - final Map attributes = new HashMap<>(); - attributes.put("key1", new Value("val1")); - final MutableContext ctx = new MutableContext(attributes); - - final Map attributes2 = new HashMap<>(); - attributes2.put("key1", new Value("val1")); - final MutableContext ctx2 = new MutableContext(attributes2); - - assertEquals(ctx, ctx2); + @Nested + class HashCode { + MutableContext ctx = new MutableContext("c", Map.of("a", new Value("b"))); + + @Test + void hashCodeEqualsLayeredEvalCtxIfSameValues() { + var layeredContext = new LayeredEvaluationContext(ctx, null, null, null); + assertEquals(layeredContext.hashCode(), ctx.hashCode()); + } + + @Test + void hashCodeEqualsDifferentMutableEvalCtxIfSameValues() { + var immutable = new ImmutableContext("c", Map.of("a", new Value("b"))); + assertEquals(immutable.hashCode(), ctx.hashCode()); + } } }