From 9ac8d9b051599a937c420a20b6bd7821a2b35f6f Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 19:33:54 +0200 Subject: [PATCH 1/7] fix(collection): match a rune or byte element in Contains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the container is a string, containsElement rendered the element with reflect.Value.String(). That returns a placeholder for any kind other than a string, so Contains(t, "hello", 'e') compared "hello" against the literal "" and failed, naming a type where the character should be. stringContainsElement now dispatches on the element instead: strings.Contains for a string, strings.ContainsRune for a rune, strings.IndexByte for a byte, and the reflect fallback only for a defined string type such as `type Doc string`. Any other kind cannot occur in a string and still reports "does not contain". Left alone: a needle that is not valid UTF-8 still matches on byte boundaries, so Contains(t, "é", "\xa9") stays true, since "\xa9" is the second byte of "é". Upstream converts both operands to []rune to prevent that, which costs an allocation on every string Contains to reject a deliberately malformed needle. reference: github.com/stretchr/testify#1908 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- internal/assertions/collection.go | 29 ++++++++++++++++++++++++-- internal/assertions/collection_test.go | 11 ++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/internal/assertions/collection.go b/internal/assertions/collection.go index c3deda309..ff03311ab 100644 --- a/internal/assertions/collection.go +++ b/internal/assertions/collection.go @@ -837,6 +837,32 @@ func isNotSubsetList(t T, list, subset any, subsetList reflect.Value, msgAndArgs // return (false, false) if impossible. // return (true, false) if element was not found. // return (true, true) if element was found. +// stringContainsElement reports whether str contains element, where element is either a +// substring or a single character. +// +// A character reaches us as a rune or a byte, and [reflect.Value.String] renders those as +// "" and "" rather than as the character, so a plain +// strings.Contains on that rendering never matches: Contains(t, "héllo", 'é') has to be +// dispatched on the element's kind instead. +func stringContainsElement(str string, element any) bool { + switch e := element.(type) { + case string: + return strings.Contains(str, e) + case rune: // int32 + return strings.ContainsRune(str, e) + case byte: // uint8 + return strings.IndexByte(str, e) >= 0 + } + + // a named string type, e.g. type Doc string + if elementValue := reflect.ValueOf(element); elementValue.Kind() == reflect.String { + return strings.Contains(str, elementValue.String()) + } + + // anything else cannot occur in a string + return false +} + func containsElement(list any, element any) (ok, found bool) { listValue := reflect.ValueOf(list) listType := reflect.TypeOf(list) @@ -852,8 +878,7 @@ func containsElement(list any, element any) (ok, found bool) { }() if listKind == reflect.String { - elementValue := reflect.ValueOf(element) - return true, strings.Contains(listValue.String(), elementValue.String()) + return true, stringContainsElement(listValue.String(), element) } if listKind == reflect.Map { diff --git a/internal/assertions/collection_test.go b/internal/assertions/collection_test.go index 47a18512b..6c594d0b5 100644 --- a/internal/assertions/collection_test.go +++ b/internal/assertions/collection_test.go @@ -468,6 +468,9 @@ const ( testStringFoo = "Foo" ) +// namedString exercises the reflect fallback for a string element of a defined type. +type namedString string + func unifiedContainsCases() iter.Seq[containsTestCase] { list := []string{testStringFoo, testStringBar} complexList := []*containsStruct{ @@ -483,6 +486,14 @@ func unifiedContainsCases() iter.Seq[containsTestCase] { // String contains {"string/contains", func() (any, any) { return "Hello World", "Hello" }, crContains, false}, {"string/not-contains", func() (any, any) { return "Hello World", "Salut" }, crNotContains, false}, + // A single character reaches Contains as a rune or a byte, never as a string. + // Reflection-only: the generic variants constrain the element to Text. + {"string/contains-rune", func() (any, any) { return "héllo", 'é' }, crContains, true}, + {"string/not-contains-rune", func() (any, any) { return "héllo", 'z' }, crNotContains, true}, + {"string/contains-byte", func() (any, any) { return "Hello World", byte('W') }, crContains, true}, + {"string/not-contains-byte", func() (any, any) { return "Hello World", byte('z') }, crNotContains, true}, + {"string/contains-named-string", func() (any, any) { return "Hello World", namedString("World") }, crContains, true}, + {"string/not-contains-other-kind", func() (any, any) { return "Hello World", 42 }, crNotContains, true}, // Slice contains {"slice-string/contains", func() (any, any) { return list, testStringBar }, crContains, false}, From 112b8048744fd802e62097d973da227a46e19068 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 19:36:27 +0200 Subject: [PATCH 2/7] fix(equality): quote string values in Empty and NotEmpty failures Empty rendered the value with %v, so a string of whitespace left nothing on screen: Empty(t, " ") reported `Should be empty, but was `, which reads as a message that lost its value. NotEmpty had the same problem with the empty string. truncatingValue picks %q for anything of string kind, aliased string types included, and keeps %v for everything else. NotEmpty now goes through it too, so its value is truncated at maxMessageSize like every other failure message instead of being printed whole. reference: github.com/stretchr/testify#1874 reference: github.com/stretchr/testify#1875 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- docs/doc-site/api/equality.md | 8 ++++---- internal/assertions/equal_unary.go | 5 ++--- internal/assertions/equal_unary_test.go | 15 +++++++++++++-- internal/assertions/format.go | 13 +++++++++++++ 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/doc-site/api/equality.md b/docs/doc-site/api/equality.md index 9be570b92..eb4170f30 100644 --- a/docs/doc-site/api/equality.md +++ b/docs/doc-site/api/equality.md @@ -190,7 +190,7 @@ func main() { |--|--| | [`assertions.Empty(t T, object any, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Empty) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Empty](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L74) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Empty](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L73) {{% /tab %}} {{< /tabs >}} @@ -891,7 +891,7 @@ func main() { |--|--| | [`assertions.Nil(t T, object any, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Nil) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Nil](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L21) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Nil](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L20) {{% /tab %}} {{< /tabs >}} @@ -1004,7 +1004,7 @@ func main() { |--|--| | [`assertions.NotEmpty(t T, object any, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#NotEmpty) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotEmpty](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L100) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotEmpty](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L99) {{% /tab %}} {{< /tabs >}} @@ -1455,7 +1455,7 @@ func main() { |--|--| | [`assertions.NotNil(t T, object any, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#NotNil) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotNil](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L43) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotNil](https://github.com/go-openapi/testify/blob/master/internal/assertions/equal_unary.go#L42) {{% /tab %}} {{< /tabs >}} diff --git a/internal/assertions/equal_unary.go b/internal/assertions/equal_unary.go index 44cc08877..405b1a5bd 100644 --- a/internal/assertions/equal_unary.go +++ b/internal/assertions/equal_unary.go @@ -4,7 +4,6 @@ package assertions import ( - "fmt" "reflect" ) @@ -79,7 +78,7 @@ func Empty(t T, object any, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } - Fail(t, "Should be empty, but was "+truncatingFormat("%v", object), msgAndArgs...) + Fail(t, "Should be empty, but was "+truncatingValue(object), msgAndArgs...) } return pass @@ -104,7 +103,7 @@ func NotEmpty(t T, object any, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } - Fail(t, fmt.Sprintf("Should NOT be empty, but was %v", object), msgAndArgs...) + Fail(t, "Should NOT be empty, but was "+truncatingValue(object), msgAndArgs...) } return pass diff --git a/internal/assertions/equal_unary_test.go b/internal/assertions/equal_unary_test.go index e3e2a1097..71cd6e9b7 100644 --- a/internal/assertions/equal_unary_test.go +++ b/internal/assertions/equal_unary_test.go @@ -171,7 +171,18 @@ func equalUnaryFailCases() iter.Seq[failCase] { { name: "Empty/non-empty-string", assertion: func(t T) bool { return Empty(t, "something") }, - wantError: "Should be empty, but was something", + wantError: `Should be empty, but was "something"`, + }, + { + // without the quotes these two report a message that looks like it lost its value + name: "Empty/whitespace-string", + assertion: func(t T) bool { return Empty(t, " ") }, + wantError: `Should be empty, but was " "`, + }, + { + name: "NotEmpty/empty-string", + assertion: func(t T) bool { return NotEmpty(t, "") }, + wantError: `Should NOT be empty, but was ""`, }, { name: "Empty/non-nil-error", @@ -206,7 +217,7 @@ func equalUnaryFailCases() iter.Seq[failCase] { { name: "Empty/aliased-string", assertion: func(t T) bool { return Empty(t, TString("abc")) }, - wantError: "Should be empty, but was abc", + wantError: `Should be empty, but was "abc"`, }, { name: "Empty/ptr-to-non-nil", diff --git a/internal/assertions/format.go b/internal/assertions/format.go index 72aaeb0d1..95c1d1c8e 100644 --- a/internal/assertions/format.go +++ b/internal/assertions/format.go @@ -7,6 +7,7 @@ import ( "bufio" "bytes" "fmt" + "reflect" "strings" ) @@ -29,6 +30,18 @@ func truncatingFormat(format string, data any) string { return value } +// truncatingValue formats a value for a failure message, quoting it when it is a string. +// +// Without the quotes a string made of whitespace, or an empty one, leaves nothing on screen: +// "Should be empty, but was" followed by two spaces reads as a message with its value missing. +func truncatingValue(data any) string { + if reflect.ValueOf(data).Kind() == reflect.String { + return truncatingFormat("%q", data) + } + + return truncatingFormat("%v", data) +} + // Aligns the provided message so that all lines after the first line start at the same location as the first line. // // Assumes that the first line starts at the correct location (after carriage return, tab, label, spacer and tab). From 0ef75eee03959ab4eb46a2e69c1d0488b405a8f2 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 19:39:02 +0200 Subject: [PATCH 3/7] fix(number): forward msgAndArgs through InEpsilonSlice The per-element call passed "at index %d" as the message, which does not add the index to what the caller wrote, it replaces it. A caller who passed InEpsilonSlice(t, want, got, eps, "checking %s", "widths") saw the index and never saw "checking widths". withContext joins the two: the positional context, then the caller's message rendered through messageFromMsgAndArgs. With nothing from the caller it yields the context alone, so a bare call still names the element that failed. reference: github.com/stretchr/testify#1931 reference: github.com/stretchr/testify#1899 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- internal/assertions/format.go | 16 +++++++++++++++ internal/assertions/number.go | 8 +++++++- internal/assertions/number_test.go | 32 ++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/internal/assertions/format.go b/internal/assertions/format.go index 95c1d1c8e..974ffc727 100644 --- a/internal/assertions/format.go +++ b/internal/assertions/format.go @@ -42,6 +42,22 @@ func truncatingValue(data any) string { return truncatingFormat("%v", data) } +// withContext prepends positional context to the caller's message, for an assertion that +// delegates to another one element by element. +// +// InEpsilonSlice checks each element with InEpsilon, and the failure comes from InEpsilon, +// which knows the two values but not which element they came from. Passing the context +// through msgAndArgs keeps the index in the message without dropping what the caller asked +// to see. +func withContext(context string, msgAndArgs []any) []any { + caller := messageFromMsgAndArgs(msgAndArgs...) + if caller == "" { + return []any{context} + } + + return []any{context + ": " + caller} +} + // Aligns the provided message so that all lines after the first line start at the same location as the first line. // // Assumes that the first line starts at the correct location (after carriage return, tab, label, spacer and tab). diff --git a/internal/assertions/number.go b/internal/assertions/number.go index 12dc2ecbc..fbca828eb 100644 --- a/internal/assertions/number.go +++ b/internal/assertions/number.go @@ -453,7 +453,13 @@ func InEpsilonSlice(t T, expected, actual any, epsilon float64, msgAndArgs ...an } for i := range expectedLen { - if !InEpsilon(t, expectedSlice.Index(i).Interface(), actualSlice.Index(i).Interface(), epsilon, "at index %d", i) { + if !InEpsilon( + t, + expectedSlice.Index(i).Interface(), + actualSlice.Index(i).Interface(), + epsilon, + withContext(fmt.Sprintf("at index %d", i), msgAndArgs)..., + ) { return false } } diff --git a/internal/assertions/number_test.go b/internal/assertions/number_test.go index 3ae2c143e..04b248a24 100644 --- a/internal/assertions/number_test.go +++ b/internal/assertions/number_test.go @@ -7,6 +7,7 @@ import ( "iter" "math" "slices" + "strings" "testing" "time" ) @@ -92,6 +93,37 @@ func TestNumberInEpsilonSlice(t *testing.T) { } } +// TestNumberInEpsilonSliceMessage checks that a per-element failure names the element and +// still carries the message the caller passed. Both land in the "Messages" part of the +// envelope, which is why this does not go through the failCase harness. +func TestNumberInEpsilonSliceMessage(t *testing.T) { + t.Parallel() + + t.Run("keeps the index and the caller message", func(t *testing.T) { + t.Parallel() + + mock := new(captureT) + InEpsilonSlice(mock, []float64{1, 2}, []float64{1, 3}, 0.01, "checking %s", "widths") + + for _, want := range []string{"at index 1", "checking widths"} { + if !strings.Contains(mock.msg, want) { + t.Errorf("message %q does not contain %q", mock.msg, want) + } + } + }) + + t.Run("names the index when the caller passed no message", func(t *testing.T) { + t.Parallel() + + mock := new(captureT) + InEpsilonSlice(mock, []float64{1, 2}, []float64{1, 3}, 0.01) + + if !strings.Contains(mock.msg, "at index 1") { + t.Errorf("message %q does not contain %q", mock.msg, "at index 1") + } + }) +} + func TestNumberInEpsilonSymmetric(t *testing.T) { t.Parallel() From 0360f0d39f85d7ba81dd54ab3110e9aea740761d Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 19:40:22 +0200 Subject: [PATCH 4/7] fix(number): name the failing key in InDeltaMapValues A missing key was already reported by name, but a key present in both maps whose values are further apart than delta was not: the failure came from InDelta, which sees the two values and not the key they were read under. On a map of any size that leaves the reader to work out which entry failed. The per-key call now carries "at key " through withContext, the same way InEpsilonSlice carries its index, so the caller's own message survives too. reference: github.com/stretchr/testify#1898 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- internal/assertions/number.go | 2 +- internal/assertions/number_test.go | 39 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/internal/assertions/number.go b/internal/assertions/number.go index fbca828eb..d7840fc05 100644 --- a/internal/assertions/number.go +++ b/internal/assertions/number.go @@ -409,7 +409,7 @@ func InDeltaMapValues(t T, expected, actual any, delta float64, msgAndArgs ...an ev.Interface(), av.Interface(), delta, - msgAndArgs..., + withContext(fmt.Sprintf("at key %v", k), msgAndArgs)..., ) { return false } diff --git a/internal/assertions/number_test.go b/internal/assertions/number_test.go index 04b248a24..64e3460bd 100644 --- a/internal/assertions/number_test.go +++ b/internal/assertions/number_test.go @@ -124,6 +124,45 @@ func TestNumberInEpsilonSliceMessage(t *testing.T) { }) } +// TestNumberInDeltaMapValuesMessage checks that a failure names the key it came from. +// InDelta reports the two values but has no idea which key they belong to. +func TestNumberInDeltaMapValuesMessage(t *testing.T) { + t.Parallel() + + t.Run("names the key and keeps the caller message", func(t *testing.T) { + t.Parallel() + + mock := new(captureT) + InDeltaMapValues(mock, + map[string]float64{"width": 1, "height": 2}, + map[string]float64{"width": 1, "height": 9}, + 0.5, + "comparing %s", "boxes", + ) + + for _, want := range []string{"at key height", "comparing boxes"} { + if !strings.Contains(mock.msg, want) { + t.Errorf("message %q does not contain %q", mock.msg, want) + } + } + }) + + t.Run("names the key when the caller passed no message", func(t *testing.T) { + t.Parallel() + + mock := new(captureT) + InDeltaMapValues(mock, + map[string]float64{"height": 2}, + map[string]float64{"height": 9}, + 0.5, + ) + + if !strings.Contains(mock.msg, "at key height") { + t.Errorf("message %q does not contain %q", mock.msg, "at key height") + } + }) +} + func TestNumberInEpsilonSymmetric(t *testing.T) { t.Parallel() From a1862b93fe72e4349a7e82dbe26d03368c99e828 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 19:41:52 +0200 Subject: [PATCH 5/7] fix(number): report expected before actual in InDeltaSlice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-element call handed InDelta the actual value first and the expected one second, so a failure read "Max difference between 9 and 2" for InDeltaSlice(t, []float64{2}, []float64{9}, 0.5) — the two named the wrong way round. The verdict was never affected, since a delta is symmetric, which is why only a message test catches it. Inherited from upstream, where the same two arguments are still swapped. No upstream issue covers it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- internal/assertions/number.go | 2 +- internal/assertions/number_test.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/assertions/number.go b/internal/assertions/number.go index d7840fc05..97d682802 100644 --- a/internal/assertions/number.go +++ b/internal/assertions/number.go @@ -354,7 +354,7 @@ func InDeltaSlice(t T, expected, actual any, delta float64, msgAndArgs ...any) b } for i := range lenActual { - result := InDelta(t, actualSlice.Index(i).Interface(), expectedSlice.Index(i).Interface(), delta, msgAndArgs...) + result := InDelta(t, expectedSlice.Index(i).Interface(), actualSlice.Index(i).Interface(), delta, msgAndArgs...) if !result { return result } diff --git a/internal/assertions/number_test.go b/internal/assertions/number_test.go index 64e3460bd..1eabbaa98 100644 --- a/internal/assertions/number_test.go +++ b/internal/assertions/number_test.go @@ -163,6 +163,21 @@ func TestNumberInDeltaMapValuesMessage(t *testing.T) { }) } +// TestNumberInDeltaSliceMessage pins the order of the two values in a per-element failure. +// The verdict does not depend on it, since the delta is symmetric, so only the message can +// catch the arguments being passed the wrong way round. +func TestNumberInDeltaSliceMessage(t *testing.T) { + t.Parallel() + + mock := new(captureT) + InDeltaSlice(mock, []float64{2}, []float64{9}, 0.5) + + // InDelta reports "Max difference between and allowed is ..." + if !strings.Contains(mock.msg, "between 2") || !strings.Contains(mock.msg, "and 9") { + t.Errorf("message %q should name expected 2 before actual 9", mock.msg) + } +} + func TestNumberInEpsilonSymmetric(t *testing.T) { t.Parallel() From 2e8b4a48ce7cb9e750df352adc204a3550244b0c Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 19:44:26 +0200 Subject: [PATCH 6/7] fix(equality): compare mixed-sign integers by value in EqualValues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EqualValues(t, int64(-1), uint64(math.MaxUint64)) passed. ObjectsAreEqualValues compares by converting one operand to the other's type, and converting a negative signed integer to an unsigned one wraps it to exactly the value it is being compared against. Converting the smaller type to the larger one, which is what keeps EqualValues(t, int(270), int8(14)) false, does not help here: int8(-1) wraps to uint64(math.MaxUint64) just as int64(-1) does. Four shapes passed wrongly — int64/uint64, int8/uint64, int/uint, and either argument order. A signed and an unsigned integer now compare by value instead: a negative signed value equals no unsigned value, and otherwise both fit in a uint64. Floats are untouched, and mixed signedness still compares equal when the values are, as in EqualValues(t, uint8(200), int64(200)). Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- internal/assertions/object.go | 42 ++++++++++++++++++++++++++++++ internal/assertions/object_test.go | 9 +++++++ 2 files changed, 51 insertions(+) diff --git a/internal/assertions/object.go b/internal/assertions/object.go index 5f96b763f..6a1d7d574 100644 --- a/internal/assertions/object.go +++ b/internal/assertions/object.go @@ -60,6 +60,14 @@ func ObjectsAreEqualValues(expected, actual any) bool { return false } + // A signed and an unsigned integer cannot be compared through a conversion: a negative + // value wraps to a huge positive one and matches it. Ordering the conversion from the + // smaller type to the larger one does not help either, since int8(-1) wraps to + // uint64(math.MaxUint64) exactly as int64(-1) does. + if isMixedSignIntegerPair(expectedType, actualType) { + return equalMixedSignIntegers(expectedValue, actualValue) + } + expectedConverted := expectedValue.Convert(actualType) if !expectedConverted.CanInterface() { // Unreachable with current Go reflection: values from reflect.ValueOf() @@ -99,6 +107,40 @@ func ObjectsAreEqualValues(expected, actual any) bool { return expectedConverted.Interface() == actual } +// isSignedInteger reports whether the type is one of int, int8, int16, int32, int64. +func isSignedInteger(t reflect.Type) bool { + return t.Kind() >= reflect.Int && t.Kind() <= reflect.Int64 +} + +// isUnsignedInteger reports whether the type is one of uint, uint8, uint16, uint32, uint64, +// uintptr. +func isUnsignedInteger(t reflect.Type) bool { + return t.Kind() >= reflect.Uint && t.Kind() <= reflect.Uintptr +} + +// isMixedSignIntegerPair reports whether one type is a signed integer and the other an +// unsigned one, in either order. +func isMixedSignIntegerPair(a, b reflect.Type) bool { + return (isSignedInteger(a) && isUnsignedInteger(b)) || (isUnsignedInteger(a) && isSignedInteger(b)) +} + +// equalMixedSignIntegers compares a signed and an unsigned integer by value rather than by +// conversion. A negative signed value equals no unsigned value at all; otherwise both fit in +// a uint64 and compare directly. +func equalMixedSignIntegers(a, b reflect.Value) bool { + signed, unsigned := a, b + if isSignedInteger(b.Type()) { + signed, unsigned = b, a + } + + value := signed.Int() + if value < 0 { + return false + } + + return uint64(value) == unsigned.Uint() +} + // isNumericType returns true if the type is one of: // int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, // float32, float64, complex64, complex128. diff --git a/internal/assertions/object_test.go b/internal/assertions/object_test.go index 50877ddb7..a52fe0091 100644 --- a/internal/assertions/object_test.go +++ b/internal/assertions/object_test.go @@ -133,6 +133,15 @@ func objectEqualValuesCases() iter.Seq[objectEqualCase] { {now, now.In(time.Local), false}, //nolint:gosmopolitan // ok in this context: this is precisely the goal of this test {int(270), int8(14), false}, // should handle overflow/underflow {int8(14), int(270), false}, + // a negative signed value wraps to a huge unsigned one under conversion + {int64(-1), uint64(math.MaxUint64), false}, + {uint64(math.MaxUint64), int64(-1), false}, + {int8(-1), uint64(math.MaxUint64), false}, + {int(-1), uint(math.MaxUint), false}, + {int(-1), uint8(math.MaxUint8), false}, + // mixed signedness still compares equal when the values are + {int64(1), uint64(1), true}, + {uint8(200), int64(200), true}, {[]int{270, 270}, []int8{14, 14}, false}, {complex128(1e+100 + 1e+100i), complex64(complex(math.Inf(0), math.Inf(0))), false}, {complex64(complex(math.Inf(0), math.Inf(0))), complex128(1e+100 + 1e+100i), false}, From fcd4e8aaadb50d069e74de1115b4074c11ac9f83 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 19:47:11 +0200 Subject: [PATCH 7/7] doc: record the upstream reports behind this round of fixes The August sweep of stretchr/testify turned up four reports describing bugs we had inherited. Each now has a row in the implemented table saying what we adopted and what we left alone, and #1942 joins #1940 as the PR behind ErrorNotContains. #1776 goes in as informational: upstream's generated require documentation lists a bool return that the functions do not have, the same defect our own API pages carried until the signature tables were corrected. Nothing to adopt, but worth recording that both projects found it independently. Two more bugs surfaced while checking those four, neither of them reported upstream, so neither gets a row: InDeltaSlice named expected and actual the wrong way round in its failures, and EqualValues compared a negative signed integer equal to a large unsigned one. Also in this file: the review-frequency line was three months stale, [#1937] was referenced with no link definition and rendered as literal text, three definitions were duplicated, and the summary counts are recounted from the tables. reference: github.com/stretchr/testify#1776 reference: github.com/stretchr/testify#1874 reference: github.com/stretchr/testify#1875 reference: github.com/stretchr/testify#1898 reference: github.com/stretchr/testify#1899 reference: github.com/stretchr/testify#1908 reference: github.com/stretchr/testify#1931 reference: github.com/stretchr/testify#1940 reference: github.com/stretchr/testify#1942 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- docs/doc-site/usage/TRACKING.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/doc-site/usage/TRACKING.md b/docs/doc-site/usage/TRACKING.md index f274fb0c2..7041b3f66 100644 --- a/docs/doc-site/usage/TRACKING.md +++ b/docs/doc-site/usage/TRACKING.md @@ -22,7 +22,11 @@ We continue to monitor and selectively adopt changes from the upstream repositor - ✅ [#1840] - JSON/YAML `Redactor` pattern (dynamic input redaction, inspired by Insta) - ✅ [#1859] - Channel assertions (`Blocked` / `NotBlocked`) - ✅ [#1860] - `ErrorAsType` / `NotErrorAsType` (go1.26+, adapted with a typed `*E` target) -- ✅ [#1940] - `ErrorNotContains` (the opposite of `ErrorContains`) +- ✅ [#1940], [#1942] - `ErrorNotContains` (the opposite of `ErrorContains`) +- ✅ [#1908] - `Contains` with a rune or byte element +- ✅ [#1874], [#1875] - quoted string values in `Empty` / `NotEmpty` failures +- ✅ [#1931], [#1899] - `InEpsilonSlice` keeps the caller's message +- ✅ [#1898] - `InDeltaMapValues` names the failing key ### Superseded by Our Implementation - ✅ [#1801] - Error message on large collections for `Len` @@ -34,7 +38,7 @@ We continue to monitor and selectively adopt changes from the upstream repositor [#1830]: https://github.com/stretchr/testify/pull/1830 [#1824]: https://github.com/stretchr/testify/pull/1824 -**Review frequency**: Quarterly (next review: May 2026) +**Review frequency**: Quarterly (last review: August 2026, next review: November 2026) --- [#1223]: https://github.com/stretchr/testify/pull/1223 @@ -87,7 +91,11 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | [#1859] | Issue | Channel assertions | ✅ Adapted | | [#1860] | Issue (PR [#1861]) | `ErrorAsType[E]` for Go 1.26+ | ✅ Adapted - implemented as `ErrorAsType` / `NotErrorAsType` with a typed `*E` target and a `bool` return (not the upstream `(E, bool)` shape), guarded by `//go:build go1.26`. First user of the codegen go-version guard. | | [#1915] | Issue | Stack overflow on recursive walk | ✅ Fixed (detected and fixed independently) | -| [#1940] | Issue/PR | `ErrorNotContains` assertion | ✅ Adapted - implemented as `ErrorNotContains` in the error domain, the opposite of `ErrorContains`: a nil error fails, and so does an error whose message contains the substring. | +| [#1874], [#1875] | PR | Quote string values in empty assertion failures | ✅ Adapted - `Empty`/`NotEmpty` render a string value with `%q`, so whitespace and the empty string stay visible. `NotEmpty` also gained the truncation every other failure message has. | +| [#1898] | PR | Include the map key in `InDeltaMapValues` error message | ✅ Adapted - the per-key call carries `at key `, alongside the caller's own message. | +| [#1899], [#1931] | PR | Pass the custom message through `InEpsilonSlice` | ✅ Adapted - the index context no longer replaces the caller's message; both are joined. | +| [#1908] | PR | `Contains` is not rune-safe on Unicode strings | ✅ Adapted - `Contains(t, "héllo", 'é')` compared against the literal `""`. Dispatch is now on the element kind (string, rune, byte, defined string type). The invalid-UTF-8 half of the upstream report is deliberately left as byte semantics. | +| [#1940], [#1942] | Issue/PR | `ErrorNotContains` assertion | ✅ Adapted - implemented as `ErrorNotContains` in the error domain, the opposite of `ErrorContains`: a nil error fails, and so does an error whose message contains the substring. | [#994]: https://github.com/stretchr/testify/pull/994 [#1232]: https://github.com/stretchr/testify/pull/1232 @@ -100,7 +108,6 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif [#1797]: https://github.com/stretchr/testify/pull/1797 [#1816]: https://github.com/stretchr/testify/issues/1816 [#1826]: https://github.com/stretchr/testify/issues/1826 -[#1829]: https://github.com/stretchr/testify/issues/1829 [#1087]: https://github.com/stretchr/testify/issues/1087 [#1606]: https://github.com/stretchr/testify/pull/1606 [#1839]: https://github.com/stretchr/testify/pull/1839 @@ -108,7 +115,15 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif [#1848]: https://github.com/stretchr/testify/pull/1848 [#1859]: https://github.com/stretchr/testify/pull/1859 [#1915]: https://github.com/stretchr/testify/issues/1915 +[#1874]: https://github.com/stretchr/testify/pull/1874 +[#1875]: https://github.com/stretchr/testify/pull/1875 +[#1898]: https://github.com/stretchr/testify/pull/1898 +[#1899]: https://github.com/stretchr/testify/pull/1899 +[#1908]: https://github.com/stretchr/testify/pull/1908 +[#1931]: https://github.com/stretchr/testify/pull/1931 +[#1937]: https://github.com/stretchr/testify/pull/1937 [#1940]: https://github.com/stretchr/testify/pull/1940 +[#1942]: https://github.com/stretchr/testify/pull/1942 ### Superseded by Our Implementation @@ -136,16 +151,16 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | [#1147] | Issue | General discussion about generics adoption | ℹ️ Marked "Not Planned" upstream - We implemented our own generics approach ({{% siteparam "metrics.generics" %}} functions) | | [#1308] | PR | Comprehensive refactor with generic type parameters | ℹ️ Draft for v2.0.0 upstream - We took a different approach with the same objective | | [#1591], [#1601] | PR + Issue | `NoFieldIsZero` recursive zero-value assertion | ⛔ **Won't do** - Considered and prototyped (2026-04-26). Same conclusion as upstream maintainers: semantics is too ambiguous (map keys, []byte, pointer targets, unexported fields, cycles, time.Time-style smart-zero types) and overlaps too heavily with [Equal](...) for legitimate use cases. Each pitfall fix adds a knob; full version is a struct validator, not an assertion. | +| [#1776] | Issue | `require` doc-comment examples call functions that return no value | ℹ️ Same defect in our generated API pages, found and fixed independently: the signature tables listed a `bool` return for every `require` row. Nothing to adopt. | | [#1862] | Issue | `CollectT` redesign / `testing.TB` interop | ⛔ **Won't do (for now)** - Studied in depth (2026-04-17). All four design options (interface widening, embedding `*testing.T`, opt-in `CollectTB` wrapper, `CollectT`-as-interface) carry visible costs; Go's `testing.TB.private()` blocks any clean proxy. Workaround for affected users is a 3-line per-helper adapter. Revisit if traction warrants the breaking churn. | [#1147]: https://github.com/stretchr/testify/issues/1147 +[#1776]: https://github.com/stretchr/testify/issues/1776 [#1308]: https://github.com/stretchr/testify/pull/1308 [#1591]: https://github.com/stretchr/testify/pull/1591 -[#1576]: https://github.com/stretchr/testify/pull/1576 [#1801]: https://github.com/stretchr/testify/pull/1801 [#1819]: https://github.com/stretchr/testify/pull/1819 [#1845]: https://github.com/stretchr/testify/pull/1845 -[#1859]: https://github.com/stretchr/testify/pull/1859 [#1860]: https://github.com/stretchr/testify/pull/1860 [#1861]: https://github.com/stretchr/testify/pull/1861 [#1862]: https://github.com/stretchr/testify/pull/1862 @@ -156,11 +171,11 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | Category | Count | |----------|-------| -| **Implemented/Merged** | 30 | +| **Implemented/Merged** | 34 | | **Superseded** | 6 | | **Monitoring** | 2 | -| **Informational** | 4 | -| **Total Processed** | 42 | +| **Informational** | 5 | +| **Total Processed** | 47 | **Note**: This fork maintains an active relationship with upstream, regularly reviewing new PRs and issues. The quarterly review process ensures we stay informed about upstream developments while maintaining our architectural independence.