From 77f015a224324a98c74160468660d15531418710 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 16 Apr 2026 10:26:39 +0200 Subject: [PATCH] feat(migration tool): added support for SliceEqualT and MapEqualT generics Signed-off-by: Frederic BIDON --- hack/migrate-testify/generics.go | 71 +++++++++++++++++++++++ hack/migrate-testify/generics_test.go | 82 +++++++++++++++++++++++++++ hack/migrate-testify/rename_map.go | 3 + 3 files changed, 156 insertions(+) diff --git a/hack/migrate-testify/generics.go b/hack/migrate-testify/generics.go index 1e6b29eb1..e667f86cf 100644 --- a/hack/migrate-testify/generics.go +++ b/hack/migrate-testify/generics.go @@ -229,6 +229,13 @@ func trySimpleUpgrade( switch baseName { case "Equal", "NotEqual": result = checkDeepComparablePair(argTypes, rule) + if !result.ok { + // Fallback: try slice or map equality upgrade. + isNot := baseName == "NotEqual" + if alt := tryEqualityFallback(argTypes, isNot); alt.ok { + return applyUpgrade(sel, funcName, alt.target, isFormat, rpt, filename, pos, verbose) + } + } case "Greater", "GreaterOrEqual", "Less", "LessOrEqual": result = checkPairConstraint(argTypes, constraintOrdered, skipNotOrdered, rule) case "InDelta", "InEpsilon": @@ -272,6 +279,70 @@ func trySimpleUpgrade( return true } +// applyUpgrade rewrites the selector name and records the upgrade. +func applyUpgrade( + sel *ast.SelectorExpr, + funcName, target string, + isFormat bool, + rpt *report, + filename string, + pos token.Position, + verbose bool, +) bool { + newName := target + if isFormat { + newName += "f" + } + if verbose { + rpt.info(filename, pos.Line, fmt.Sprintf("upgraded %s → %s", funcName, newName)) + } + rpt.trackUpgrade(funcName, newName) + sel.Sel.Name = newName + return true +} + +// tryEqualityFallback attempts slice or map equality upgrades when the +// standard Equal → EqualT path is not applicable (e.g., slices and maps +// are not deep-comparable scalars). +func tryEqualityFallback(argTypes []types.Type, isNot bool) containerCheckResult { + if len(argTypes) < minPairArgs { + return containerCheckResult{} + } + + if !sameType(argTypes[0], argTypes[1]) { + return containerCheckResult{checkResult: checkSkip(skipTypeMismatch, argTypes[0].String()+" vs "+argTypes[1].String())} + } + + // Try slice equality: both args are []E with E comparable. + if elem, ok := isSliceType(argTypes[0]); ok { + if !isComparable(elem) { + return containerCheckResult{checkResult: checkSkip(skipSliceElemNotComparable, elem.String())} + } + target := "SliceEqualT" + if isNot { + target = "SliceNotEqualT" + } + return containerCheckResult{checkResult: checkOK, target: target} + } + + // Try map equality: both args are map[K]V with K and V comparable. + if key, val, ok := isMapType(argTypes[0]); ok { + if !isComparable(key) { + return containerCheckResult{checkResult: checkSkip(skipMapKeyNotComparable, key.String())} + } + if !isComparable(val) { + return containerCheckResult{checkResult: checkSkip(skipMapValNotComparable, val.String())} + } + target := "MapEqualT" + if isNot { + target = "MapNotEqualT" + } + return containerCheckResult{checkResult: checkOK, target: target} + } + + return containerCheckResult{} +} + // checkDeepComparablePair checks that both arguments are deeply comparable and have matching types. func checkDeepComparablePair(argTypes []types.Type, rule upgradeRule) checkResult { if len(argTypes) < minPairArgs { diff --git a/hack/migrate-testify/generics_test.go b/hack/migrate-testify/generics_test.go index 7ff9a969a..d8051c9e5 100644 --- a/hack/migrate-testify/generics_test.go +++ b/hack/migrate-testify/generics_test.go @@ -135,6 +135,78 @@ func TestContains(t *testing.T) { }`, expected: `assert.MapContainsT(t, map[string]int{"a": 1}, "a")`, }, + { + name: "equal slice int upgrade", + input: `package p +import ( + "testing" + "github.com/go-openapi/testify/v2/assert" +) +func TestSliceEqual(t *testing.T) { + assert.Equal(t, []int{1, 2}, []int{3, 4}) +}`, + expected: `assert.SliceEqualT(t, []int{1, 2}, []int{3, 4})`, + }, + { + name: "notequal slice string upgrade", + input: `package p +import ( + "testing" + "github.com/go-openapi/testify/v2/assert" +) +func TestSliceNotEqual(t *testing.T) { + assert.NotEqual(t, []string{"a"}, []string{"b"}) +}`, + expected: `assert.SliceNotEqualT(t, []string{"a"}, []string{"b"})`, + }, + { + name: "equal map upgrade", + input: `package p +import ( + "testing" + "github.com/go-openapi/testify/v2/assert" +) +func TestMapEqual(t *testing.T) { + assert.Equal(t, map[string]int{"a": 1}, map[string]int{"b": 2}) +}`, + expected: `assert.MapEqualT(t, map[string]int{"a": 1}, map[string]int{"b": 2})`, + }, + { + name: "notequal map upgrade", + input: `package p +import ( + "testing" + "github.com/go-openapi/testify/v2/assert" +) +func TestMapNotEqual(t *testing.T) { + assert.NotEqual(t, map[string]int{"a": 1}, map[string]int{"b": 2}) +}`, + expected: `assert.MapNotEqualT(t, map[string]int{"a": 1}, map[string]int{"b": 2})`, + }, + { + name: "equalf slice format variant", + input: `package p +import ( + "testing" + "github.com/go-openapi/testify/v2/assert" +) +func TestSliceEqualf(t *testing.T) { + assert.Equalf(t, []int{1, 2}, []int{3, 4}, "should match") +}`, + expected: `assert.SliceEqualTf(t, []int{1, 2}, []int{3, 4}, "should match")`, + }, + { + name: "skip slice non-comparable element", + input: `package p +import ( + "testing" + "github.com/go-openapi/testify/v2/assert" +) +func TestSkipSliceNonComparable(t *testing.T) { + assert.Equal(t, [][]int{{1}}, [][]int{{2}}) +}`, + expected: `assert.Equal(t, [][]int{{1}}, [][]int{{2}})`, + }, { name: "true/false bool upgrade", input: `package p @@ -386,6 +458,16 @@ func typeCheckWithMockAssert(t *testing.T, fset *token.FileSet, f *ast.File) *ty makeAssertFunc("StringContainsT", anyParam("s"), anyParam("contains")) makeAssertFunc("SliceContainsT", anyParam("s"), anyParam("contains")) makeAssertFunc("MapContainsT", anyParam("s"), anyParam("contains")) + makeAssertFunc("Equalf", anyParam("expected"), anyParam("actual")) + makeAssertFunc("NotEqualf", anyParam("expected"), anyParam("actual")) + makeAssertFunc("SliceEqualT", anyParam("expected"), anyParam("actual")) + makeAssertFunc("SliceEqualTf", anyParam("expected"), anyParam("actual")) + makeAssertFunc("SliceNotEqualT", anyParam("expected"), anyParam("actual")) + makeAssertFunc("SliceNotEqualTf", anyParam("expected"), anyParam("actual")) + makeAssertFunc("MapEqualT", anyParam("expected"), anyParam("actual")) + makeAssertFunc("MapEqualTf", anyParam("expected"), anyParam("actual")) + makeAssertFunc("MapNotEqualT", anyParam("expected"), anyParam("actual")) + makeAssertFunc("MapNotEqualTf", anyParam("expected"), anyParam("actual")) makeAssertFunc("True", anyParam("value")) makeAssertFunc("TrueT", anyParam("value")) makeAssertFunc("False", anyParam("value")) diff --git a/hack/migrate-testify/rename_map.go b/hack/migrate-testify/rename_map.go index f3d88afbf..acc51ff02 100644 --- a/hack/migrate-testify/rename_map.go +++ b/hack/migrate-testify/rename_map.go @@ -277,6 +277,9 @@ const ( skipNotSlice skipReason = "argument is not a slice type" skipNotRegExp skipReason = "type does not satisfy RegExp constraint" skipSliceElemNotDeepComparable skipReason = "slice element not deeply comparable" + skipSliceElemNotComparable skipReason = "slice element not comparable" skipSliceElemNotOrdered skipReason = "slice element not ordered" + skipMapKeyNotComparable skipReason = "map key not comparable" + skipMapValNotComparable skipReason = "map value not comparable" skipContainerTypeUnknown skipReason = "container is not string, slice, or map" )