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/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. 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}, 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..974ffc727 100644 --- a/internal/assertions/format.go +++ b/internal/assertions/format.go @@ -7,6 +7,7 @@ import ( "bufio" "bytes" "fmt" + "reflect" "strings" ) @@ -29,6 +30,34 @@ 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) +} + +// 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..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 } @@ -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 } @@ -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..1eabbaa98 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,91 @@ 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") + } + }) +} + +// 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") + } + }) +} + +// 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() 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},