From 816c30eb659360458a464615769b1c66fe35124c Mon Sep 17 00:00:00 2001 From: Paul King Date: Mon, 18 May 2026 11:18:46 +1000 Subject: [PATCH] GROOVY-12017: Tuple equals should follow Groovy's List equals semantics --- src/main/java/groovy/lang/Tuple.java | 2 +- .../DefaultTypeTransformation.java | 8 ++++ src/test/groovy/groovy/lang/TupleTest.java | 37 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/main/java/groovy/lang/Tuple.java b/src/main/java/groovy/lang/Tuple.java index b6204abdd1c..bd37eebfb9c 100644 --- a/src/main/java/groovy/lang/Tuple.java +++ b/src/main/java/groovy/lang/Tuple.java @@ -127,7 +127,7 @@ public Tuple subTuple(int fromIndex, int toIndex) { @Override public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof Tuple that)) return false; + if (!(o instanceof List that)) return false; int size = size(); if (size != that.size()) return false; diff --git a/src/main/java/org/codehaus/groovy/runtime/typehandling/DefaultTypeTransformation.java b/src/main/java/org/codehaus/groovy/runtime/typehandling/DefaultTypeTransformation.java index aa62aa7344e..611e01c07d3 100644 --- a/src/main/java/org/codehaus/groovy/runtime/typehandling/DefaultTypeTransformation.java +++ b/src/main/java/org/codehaus/groovy/runtime/typehandling/DefaultTypeTransformation.java @@ -1190,6 +1190,14 @@ public static boolean compareEqual(Object left, Object right) { if (left == right) return true; if (left == null) return right instanceof NullObject; if (right == null) return left instanceof NullObject; + // An object that is both Comparable and a List (e.g. Tuple) must use + // list equality here, consistent with how a plain (non-Comparable) + // List is compared, rather than taking the Comparable/compareTo + // short-circuit below; otherwise `tuple == [..]` would be asymmetric + // with `[..] == tuple` and inconsistent with Tuple.equals(List). + if (left instanceof List && right instanceof List) { + return DefaultGroovyMethods.equals((List) left, (List) right); + } if (left instanceof Comparable) { return compareToWithEqualityCheck(left, right, true) == 0; } diff --git a/src/test/groovy/groovy/lang/TupleTest.java b/src/test/groovy/groovy/lang/TupleTest.java index bfe3ecb3e60..12d39a3d137 100644 --- a/src/test/groovy/groovy/lang/TupleTest.java +++ b/src/test/groovy/groovy/lang/TupleTest.java @@ -21,6 +21,7 @@ import groovy.test.GroovyTestCase; import org.codehaus.groovy.runtime.DefaultGroovyMethods; +import java.util.Arrays; import java.util.HashSet; import java.util.Iterator; import java.util.List; @@ -293,4 +294,40 @@ public void testGroovyStyleAccessor() { assert false : e.getMessage(); } } + + // Regression coverage for making Tuple equality symmetric with List + // (broaden Tuple.equals from `instanceof Tuple` to `instanceof List`, + // keeping Groovy's number-aware element comparison consistent with + // DefaultGroovyMethods.equals(List, List)). + public void testTupleEqualsBehavesLikeList() throws Exception { + Tuple2 t = new Tuple2<>(1, 2); + List list = Arrays.asList(1, 2); + + // .equals() symmetric in both operand orders + assertEquals("Tuple2.equals(List)", t, list); + assertEquals("List.equals(Tuple2)", list, t); + // hashCode is already the List formula, so it stays consistent + assertEquals("hashCode parity with List", t.hashCode(), list.hashCode()); + + // Groovy == in both operand orders + assertScript( + "def t = new Tuple2(1, 2)\n" + + "assert t == [1, 2]\n" + + "assert [1, 2] == t\n" + + // number-aware elements, consistent with equals(List, List) + "assert t == [1L, 2L]\n" + + "assert [1L, 2L] == t\n"); + + // number-aware elements via .equals(), consistent with equals(List, List) + assertEquals("number-aware elements", t, Arrays.asList(1L, 2L)); + + // unchanged: Tuple vs Tuple + assertEquals("Tuple vs Tuple", new Tuple2<>(1, 2), new Tuple2<>(1, 2)); + // empty tuple vs empty list + assertEquals("empty Tuple vs empty List", tuple(), Arrays.asList()); + + // guardrails: differing size / content are not equal + assertNotEquals(t, Arrays.asList(1, 2, 3), "size mismatch"); + assertNotEquals(t, Arrays.asList(1, 9), "content mismatch"); + } }