diff --git a/.gitignore b/.gitignore index 1680db44c..872d1cd31 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ Godeps .idea *.out .mcp.json +go.work.sum diff --git a/assert/assert_assertions.go b/assert/assert_assertions.go index 6d87540d1..3ffa69906 100644 --- a/assert/assert_assertions.go +++ b/assert/assert_assertions.go @@ -1209,6 +1209,67 @@ func InEpsilonSlice(t T, expected any, actual any, epsilon float64, msgAndArgs . return assertions.InEpsilonSlice(t, expected, actual, epsilon, msgAndArgs...) } +// InEpsilonSymmetric asserts that 2 numbers are close, with a symmetric relative error. +// +// Unlike with [InEpsilon], both numbers play a symmetric role and the relative error is +// computed relative to the number with greatest amplitude. This mirrors the behavior of +// Python's [math.isclose] (with the relative-tolerance term only). +// +// See also [InEpsilon]. +// +// # Behavior with IEEE floating point arithmetic +// +// - NaN is matched only by a NaN, e.g. this works: [InEpsilonSymmetric]([math.NaN](), [math.Sqrt](-1), 0.0) +// - +Inf is matched only by a +Inf +// - -Inf is matched only by a -Inf +// +// Edge case: for very large integers that do not convert accurately to a float64 (e.g. uint64), prefer [InDelta]. +// +// Formula: +// +// - If x == 0 and y == 0: success +// - Otherwise fail if |x - y| > epsilon * max(|x|,|y|) +// +// # Usage +// +// assertions.InEpsilonSymmetric(t, 100.0, 101.0, 0.02) +// +// # Examples +// +// success: 100.0, 101.0, 0.02 +// failure: 100.0, 110.0, 0.05 +// +// Upon failure, the test [T] is marked as failed and continues execution. +// +// [math.isclose]: https://docs.python.org/3/library/math.html#math.isclose +func InEpsilonSymmetric(t T, x any, y any, epsilon float64, msgAndArgs ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + return assertions.InEpsilonSymmetric(t, x, y, epsilon, msgAndArgs...) +} + +// InEpsilonSymmetricT is the type-safe version of [InEpsilonSymmetric], comparing numbers of the same numerical type. +// +// See [InEpsilonSymmetric]. +// +// # Usage +// +// assertions.InEpsilonSymmetricT(t, 100.0, 101.0, 0.02) +// +// # Examples +// +// success: 100.0, 101.0, 0.02 +// failure: 100.0, 110.0, 0.05 +// +// Upon failure, the test [T] is marked as failed and continues execution. +func InEpsilonSymmetricT[Number Measurable](t T, x Number, y Number, epsilon float64, msgAndArgs ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + return assertions.InEpsilonSymmetricT[Number](t, x, y, epsilon, msgAndArgs...) +} + // InEpsilonT asserts that expected and actual have a relative error less than epsilon. // // When expected is zero, epsilon is interpreted as an absolute error threshold, diff --git a/assert/assert_assertions_test.go b/assert/assert_assertions_test.go index e21e7701a..e5622eb31 100644 --- a/assert/assert_assertions_test.go +++ b/assert/assert_assertions_test.go @@ -1225,6 +1225,60 @@ func TestInEpsilonSlice(t *testing.T) { }) } +func TestInEpsilonSymmetric(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := InEpsilonSymmetric(mock, 100.0, 101.0, 0.02) + if !result { + t.Error("InEpsilonSymmetric should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := InEpsilonSymmetric(mock, 100.0, 110.0, 0.05) + if result { + t.Error("InEpsilonSymmetric should return false on failure") + } + if !mock.failed { + t.Error("InEpsilonSymmetric should mark test as failed") + } + }) +} + +func TestInEpsilonSymmetricT(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := InEpsilonSymmetricT(mock, 100.0, 101.0, 0.02) + if !result { + t.Error("InEpsilonSymmetricT should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := InEpsilonSymmetricT(mock, 100.0, 110.0, 0.05) + if result { + t.Error("InEpsilonSymmetricT should return false on failure") + } + if !mock.failed { + t.Error("InEpsilonSymmetricT should mark test as failed") + } + }) +} + func TestInEpsilonT(t *testing.T) { t.Parallel() diff --git a/assert/assert_examples_test.go b/assert/assert_examples_test.go index 9c6941b32..35d913e93 100644 --- a/assert/assert_examples_test.go +++ b/assert/assert_examples_test.go @@ -380,6 +380,22 @@ func ExampleInEpsilonSlice() { // Output: success: true } +func ExampleInEpsilonSymmetric() { + t := new(testing.T) // should come from testing, e.g. func TestInEpsilonSymmetric(t *testing.T) + success := assert.InEpsilonSymmetric(t, 100.0, 101.0, 0.02) + fmt.Printf("success: %t\n", success) + + // Output: success: true +} + +func ExampleInEpsilonSymmetricT() { + t := new(testing.T) // should come from testing, e.g. func TestInEpsilonSymmetricT(t *testing.T) + success := assert.InEpsilonSymmetricT(t, 100.0, 101.0, 0.02) + fmt.Printf("success: %t\n", success) + + // Output: success: true +} + func ExampleInEpsilonT() { t := new(testing.T) // should come from testing, e.g. func TestInEpsilonT(t *testing.T) success := assert.InEpsilonT(t, 100.0, 101.0, 0.02) diff --git a/assert/assert_format.go b/assert/assert_format.go index bd5b00453..6af8c4fed 100644 --- a/assert/assert_format.go +++ b/assert/assert_format.go @@ -465,6 +465,26 @@ func InEpsilonSlicef(t T, expected any, actual any, epsilon float64, msg string, return assertions.InEpsilonSlice(t, expected, actual, epsilon, forwardArgs(msg, args)) } +// InEpsilonSymmetricf is the same as [InEpsilonSymmetric], but it accepts a format string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and continues execution. +func InEpsilonSymmetricf(t T, x any, y any, epsilon float64, msg string, args ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + return assertions.InEpsilonSymmetric(t, x, y, epsilon, forwardArgs(msg, args)) +} + +// InEpsilonSymmetricTf is the same as [InEpsilonSymmetricT], but it accepts a format string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and continues execution. +func InEpsilonSymmetricTf[Number Measurable](t T, x Number, y Number, epsilon float64, msg string, args ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + return assertions.InEpsilonSymmetricT[Number](t, x, y, epsilon, forwardArgs(msg, args)) +} + // InEpsilonTf is the same as [InEpsilonT], but it accepts a format string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and continues execution. diff --git a/assert/assert_format_test.go b/assert/assert_format_test.go index fcea9885b..f7c8f67f6 100644 --- a/assert/assert_format_test.go +++ b/assert/assert_format_test.go @@ -1225,6 +1225,60 @@ func TestInEpsilonSlicef(t *testing.T) { }) } +func TestInEpsilonSymmetricf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := InEpsilonSymmetricf(mock, 100.0, 101.0, 0.02, "test message") + if !result { + t.Error("InEpsilonSymmetricf should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := InEpsilonSymmetricf(mock, 100.0, 110.0, 0.05, "test message") + if result { + t.Error("InEpsilonSymmetricf should return false on failure") + } + if !mock.failed { + t.Error("InEpsilonSymmetricf should mark test as failed") + } + }) +} + +func TestInEpsilonSymmetricTf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := InEpsilonSymmetricTf(mock, 100.0, 101.0, 0.02, "test message") + if !result { + t.Error("InEpsilonSymmetricTf should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := InEpsilonSymmetricTf(mock, 100.0, 110.0, 0.05, "test message") + if result { + t.Error("InEpsilonSymmetricTf should return false on failure") + } + if !mock.failed { + t.Error("InEpsilonSymmetricTf should mark test as failed") + } + }) +} + func TestInEpsilonTf(t *testing.T) { t.Parallel() diff --git a/assert/assert_forward.go b/assert/assert_forward.go index f1f7c8b24..bb7614204 100644 --- a/assert/assert_forward.go +++ b/assert/assert_forward.go @@ -750,6 +750,26 @@ func (a *Assertions) InEpsilonSlicef(expected any, actual any, epsilon float64, return assertions.InEpsilonSlice(a.T, expected, actual, epsilon, forwardArgs(msg, args)) } +// InEpsilonSymmetric is the same as [InEpsilonSymmetric], as a method rather than a package-level function. +// +// Upon failure, the test [T] is marked as failed and continues execution. +func (a *Assertions) InEpsilonSymmetric(x any, y any, epsilon float64, msgAndArgs ...any) bool { + if h, ok := a.T.(H); ok { + h.Helper() + } + return assertions.InEpsilonSymmetric(a.T, x, y, epsilon, msgAndArgs...) +} + +// InEpsilonSymmetricf is the same as [Assertions.InEpsilonSymmetric], but it accepts a format string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and continues execution. +func (a *Assertions) InEpsilonSymmetricf(x any, y any, epsilon float64, msg string, args ...any) bool { + if h, ok := a.T.(H); ok { + h.Helper() + } + return assertions.InEpsilonSymmetric(a.T, x, y, epsilon, forwardArgs(msg, args)) +} + // IsDecreasing is the same as [IsDecreasing], as a method rather than a package-level function. // // Upon failure, the test [T] is marked as failed and continues execution. diff --git a/assert/assert_forward_test.go b/assert/assert_forward_test.go index bf6846731..455a03729 100644 --- a/assert/assert_forward_test.go +++ b/assert/assert_forward_test.go @@ -1038,6 +1038,35 @@ func TestAssertionsInEpsilonSlice(t *testing.T) { }) } +func TestAssertionsInEpsilonSymmetric(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + a := New(mock) + result := a.InEpsilonSymmetric(100.0, 101.0, 0.02) + if !result { + t.Error("Assertions.InEpsilonSymmetric should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + a := New(mock) + result := a.InEpsilonSymmetric(100.0, 110.0, 0.05) + if result { + t.Error("Assertions.InEpsilonSymmetric should return false on failure") + } + if !mock.failed { + t.Error("Assertions.InEpsilonSymmetric should mark test as failed") + } + }) +} + func TestAssertionsIsDecreasing(t *testing.T) { t.Parallel() @@ -3319,6 +3348,35 @@ func TestAssertionsInEpsilonSlicef(t *testing.T) { }) } +func TestAssertionsInEpsilonSymmetricf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + a := New(mock) + result := a.InEpsilonSymmetricf(100.0, 101.0, 0.02, "test message") + if !result { + t.Error("Assertions.InEpsilonSymmetricf should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + a := New(mock) + result := a.InEpsilonSymmetricf(100.0, 110.0, 0.05, "test message") + if result { + t.Error("Assertions.InEpsilonSymmetricf should return false on failure") + } + if !mock.failed { + t.Error("Assertions.InEpsilonSymmetricf should mark test as failed") + } + }) +} + func TestAssertionsIsDecreasingf(t *testing.T) { t.Parallel() diff --git a/docs/doc-site/api/_index.md b/docs/doc-site/api/_index.md index 488ebca1c..46c7fac0c 100644 --- a/docs/doc-site/api/_index.md +++ b/docs/doc-site/api/_index.md @@ -40,7 +40,7 @@ Each domain contains assertions regrouped by their use case (e.g. http, json, er - [File](./file.md) - Asserting OS Files (6) - [Http](./http.md) - Asserting HTTP Response And Body (7) - [Json](./json.md) - Asserting JSON Documents (5) -- [Number](./number.md) - Asserting Numbers (7) +- [Number](./number.md) - Asserting Numbers (9) - [Ordering](./ordering.md) - Asserting How Collections Are Ordered (10) - [Panic](./panic.md) - Asserting A Panic Behavior (4) - [Safety](./safety.md) - Checks Against Leaked Resources (Goroutines, File Descriptors) (2) diff --git a/docs/doc-site/api/metrics.md b/docs/doc-site/api/metrics.md index fd241586e..3dc2283ea 100644 --- a/docs/doc-site/api/metrics.md +++ b/docs/doc-site/api/metrics.md @@ -15,14 +15,14 @@ Counts for core functionality, and generated variants (formatted, forward, forwa | Kind | Count | Note | | ------------------------- | ----------------- | ---- | -| All core functions | 135 | Maintained core | -| All core assertions | 131 | Usage with `*testing.T` | -| Generic assertions | 50 | Type-safe assertions ("T" suffix) | +| All core functions | 137 | Maintained core | +| All core assertions | 133 | Usage with `*testing.T` | +| Generic assertions | 51 | Type-safe assertions ("T" suffix) | | Helpers (not assertions) | 4 | General-purpose utilities, not assertions | | Others | 0 | | -| assert/require variants | 424 | Generated variants | -| Total assertions variants | 848 | Available assertions API | -| Total API surface | 858 | | +| assert/require variants | 430 | Generated variants | +| Total assertions variants | 860 | Available assertions API | +| Total API surface | 870 | | ## Quick index @@ -71,6 +71,8 @@ Table of core assertions, excluding variants. Each function is side by side with | [InDeltaT[Number Measurable]](number/#indeltatnumber-measurable) {{% icon icon="star" color=orange %}} | | number | | | [InEpsilon](number/#inepsilon) | | number | | | [InEpsilonSlice](number/#inepsilonslice) | | number | | +| [InEpsilonSymmetric](number/#inepsilonsymmetric) | | number | | +| [InEpsilonSymmetricT[Number Measurable]](number/#inepsilonsymmetrictnumber-measurable) {{% icon icon="star" color=orange %}} | | number | | | [InEpsilonT[Number Measurable]](number/#inepsilontnumber-measurable) {{% icon icon="star" color=orange %}} | | number | | | [IsDecreasing](ordering/#isdecreasing) | [IsNonDecreasing](ordering/#isnondecreasing) | ordering | | | [IsDecreasingT[OrderedSlice ~[]E, E Ordered]](ordering/#isdecreasingtorderedslice-e-e-ordered) {{% icon icon="star" color=orange %}} | [IsNonDecreasingT](ordering/#isnondecreasingtorderedslice-e-e-ordered) | ordering | | diff --git a/docs/doc-site/api/number.md b/docs/doc-site/api/number.md index 953435712..65ce72d74 100644 --- a/docs/doc-site/api/number.md +++ b/docs/doc-site/api/number.md @@ -17,6 +17,10 @@ keywords: - "InEpsilonf" - "InEpsilonSlice" - "InEpsilonSlicef" + - "InEpsilonSymmetric" + - "InEpsilonSymmetricf" + - "InEpsilonSymmetricT" + - "InEpsilonSymmetricTf" - "InEpsilonT" - "InEpsilonTf" --- @@ -30,7 +34,7 @@ Asserting Numbers _All links point to _ -This domain exposes 7 functionalities. +This domain exposes 9 functionalities. Generic assertions are marked with a {{% icon icon="star" color=orange %}}. ```tree @@ -40,6 +44,8 @@ Generic assertions are marked with a {{% icon icon="star" color=orange %}}. - [InDeltaT[Number Measurable]](#indeltatnumber-measurable) | star | orange - [InEpsilon](#inepsilon) | angles-right - [InEpsilonSlice](#inepsilonslice) | angles-right +- [InEpsilonSymmetric](#inepsilonsymmetric) | angles-right +- [InEpsilonSymmetricT[Number Measurable]](#inepsilonsymmetrictnumber-measurable) | star | orange - [InEpsilonT[Number Measurable]](#inepsilontnumber-measurable) | star | orange ``` @@ -275,7 +281,7 @@ func main() { |--|--| | [`assertions.InDeltaMapValues(t T, expected any, actual any, delta float64, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#InDeltaMapValues) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#InDeltaMapValues](https://github.com/go-openapi/testify/blob/master/internal/assertions/number.go#L273) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#InDeltaMapValues](https://github.com/go-openapi/testify/blob/master/internal/assertions/number.go#L367) {{% /tab %}} {{< /tabs >}} @@ -388,7 +394,7 @@ func main() { |--|--| | [`assertions.InDeltaSlice(t T, expected any, actual any, delta float64, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#InDeltaSlice) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#InDeltaSlice](https://github.com/go-openapi/testify/blob/master/internal/assertions/number.go#L237) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#InDeltaSlice](https://github.com/go-openapi/testify/blob/master/internal/assertions/number.go#L331) {{% /tab %}} {{< /tabs >}} @@ -746,7 +752,248 @@ func main() { |--|--| | [`assertions.InEpsilonSlice(t T, expected any, actual any, epsilon float64, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#InEpsilonSlice) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#InEpsilonSlice](https://github.com/go-openapi/testify/blob/master/internal/assertions/number.go#L328) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#InEpsilonSlice](https://github.com/go-openapi/testify/blob/master/internal/assertions/number.go#L422) +{{% /tab %}} +{{< /tabs >}} + +### InEpsilonSymmetric{#inepsilonsymmetric} +InEpsilonSymmetric asserts that 2 numbers are close, with a symmetric relative error. + +Unlike with [InEpsilon](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#InEpsilon), both numbers play a symmetric role and the relative error is +computed relative to the number with greatest amplitude. This mirrors the behavior of +Python's [math.isclose](https://docs.python.org/3/library/math.html#math.isclose) (with the relative-tolerance term only). + +See also [InEpsilon](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#InEpsilon). + + + +#### Behavior with IEEE floating point arithmetic + + - NaN is matched only by a NaN, e.g. this works: [InEpsilonSymmetric]([math.NaN](), [math.Sqrt](-1), 0.0) + - +Inf is matched only by a +Inf + - -Inf is matched only by a -Inf + +Edge case: for very large integers that do not convert accurately to a float64 (e.g. uint64), prefer [InDelta](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#InDelta). + +Formula: + + - If x == 0 and y == 0: success + - Otherwise fail if |x - y| > epsilon * max(|x|,|y|) + +{{% expand title="Examples" %}} +{{< tabs >}} +{{% tab title="Usage" %}} +```go + assertions.InEpsilonSymmetric(t, 100.0, 101.0, 0.02) + success: 100.0, 101.0, 0.02 + failure: 100.0, 110.0, 0.05 +``` +{{< /tab >}} +{{% tab title="Testable Examples (assert)" %}} +{{% cards %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestInEpsilonSymmetric(t *testing.T) +package main + +import ( + "fmt" + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // should come from testing, e.g. func TestInEpsilonSymmetric(t *testing.T) + success := assert.InEpsilonSymmetric(t, 100.0, 101.0, 0.02) + fmt.Printf("success: %t\n", success) + +} + +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{% tab title="Testable Examples (require)" %}} +{{% cards %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestInEpsilonSymmetric(t *testing.T) +package main + +import ( + "fmt" + "testing" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // should come from testing, e.g. func TestInEpsilonSymmetric(t *testing.T) + require.InEpsilonSymmetric(t, 100.0, 101.0, 0.02) + fmt.Println("passed") + +} + +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{< /tabs >}} +{{% /expand %}} + +{{< tabs >}} + +{{% tab title="assert" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`assert.InEpsilonSymmetric(t T, x any, y any, epsilon float64, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#InEpsilonSymmetric) | package-level function | +| [`assert.InEpsilonSymmetricf(t T, x any, y any, epsilon float64, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#InEpsilonSymmetricf) | formatted variant | +| [`assert.(*Assertions).InEpsilonSymmetric(x any, y any, epsilon float64) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.InEpsilonSymmetric) | method variant | +| [`assert.(*Assertions).InEpsilonSymmetricf(x any, y any, epsilon float64, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.InEpsilonSymmetricf) | method formatted variant | +{{% /tab %}} +{{% tab title="require" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`require.InEpsilonSymmetric(t T, x any, y any, epsilon float64, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#InEpsilonSymmetric) | package-level function | +| [`require.InEpsilonSymmetricf(t T, x any, y any, epsilon float64, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#InEpsilonSymmetricf) | formatted variant | +| [`require.(*Assertions).InEpsilonSymmetric(x any, y any, epsilon float64) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.InEpsilonSymmetric) | method variant | +| [`require.(*Assertions).InEpsilonSymmetricf(x any, y any, epsilon float64, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.InEpsilonSymmetricf) | method formatted variant | +{{% /tab %}} + +{{% tab title="internal" style="accent" icon="wrench" %}} +| Signature | Usage | +|--|--| +| [`assertions.InEpsilonSymmetric(t T, x any, y any, epsilon float64, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#InEpsilonSymmetric) | internal implementation | + +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#InEpsilonSymmetric](https://github.com/go-openapi/testify/blob/master/internal/assertions/number.go#L256) +{{% /tab %}} +{{< /tabs >}} + +### InEpsilonSymmetricT[Number Measurable] {{% icon icon="star" color=orange %}}{#inepsilonsymmetrictnumber-measurable} +InEpsilonSymmetricT is the type-safe version of [InEpsilonSymmetric](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#InEpsilonSymmetric), comparing numbers of the same numerical type. + +See [InEpsilonSymmetric](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#InEpsilonSymmetric). + +{{% expand title="Examples" %}} +{{< tabs >}} +{{% tab title="Usage" %}} +```go + assertions.InEpsilonSymmetricT(t, 100.0, 101.0, 0.02) + success: 100.0, 101.0, 0.02 + failure: 100.0, 110.0, 0.05 +``` +{{< /tab >}} +{{% tab title="Testable Examples (assert)" %}} +{{% cards %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestInEpsilonSymmetricT(t *testing.T) +package main + +import ( + "fmt" + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // should come from testing, e.g. func TestInEpsilonSymmetricT(t *testing.T) + success := assert.InEpsilonSymmetricT(t, 100.0, 101.0, 0.02) + fmt.Printf("success: %t\n", success) + +} + +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{% tab title="Testable Examples (require)" %}} +{{% cards %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestInEpsilonSymmetricT(t *testing.T) +package main + +import ( + "fmt" + "testing" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // should come from testing, e.g. func TestInEpsilonSymmetricT(t *testing.T) + require.InEpsilonSymmetricT(t, 100.0, 101.0, 0.02) + fmt.Println("passed") + +} + +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{< /tabs >}} +{{% /expand %}} + +{{< tabs >}} + +{{% tab title="assert" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`assert.InEpsilonSymmetricT[Number Measurable](t T, x Number, y Number, epsilon float64, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#InEpsilonSymmetricT) | package-level function | +| [`assert.InEpsilonSymmetricTf[Number Measurable](t T, x Number, y Number, epsilon float64, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#InEpsilonSymmetricTf) | formatted variant | +{{% /tab %}} +{{% tab title="require" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`require.InEpsilonSymmetricT[Number Measurable](t T, x Number, y Number, epsilon float64, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#InEpsilonSymmetricT) | package-level function | +| [`require.InEpsilonSymmetricTf[Number Measurable](t T, x Number, y Number, epsilon float64, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#InEpsilonSymmetricTf) | formatted variant | +{{% /tab %}} + +{{% tab title="internal" style="accent" icon="wrench" %}} +| Signature | Usage | +|--|--| +| [`assertions.InEpsilonSymmetricT[Number Measurable](t T, x Number, y Number, epsilon float64, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#InEpsilonSymmetricT) | internal implementation | + +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#InEpsilonSymmetricT](https://github.com/go-openapi/testify/blob/master/internal/assertions/number.go#L295) {{% /tab %}} {{< /tabs >}} diff --git a/docs/doc-site/project/maintainers/ARCHITECTURE.md b/docs/doc-site/project/maintainers/ARCHITECTURE.md index 901fad049..243c2d3c9 100644 --- a/docs/doc-site/project/maintainers/ARCHITECTURE.md +++ b/docs/doc-site/project/maintainers/ARCHITECTURE.md @@ -55,7 +55,7 @@ All these variants make up several hundreds functions, which poses a challenge f We have adopted code and documentation generation as a means to mitigate this issue. -#### Current (v2.5.0-unreleased) +#### Current 1. Generic assertions (with type parameters): {{% siteparam "metrics.generics" %}} functions 2. Non-generic assertions (with t T parameter, no type parameters): {{% siteparam "metrics.nongeneric_assertions" %}} functions diff --git a/docs/doc-site/usage/CHANGES.md b/docs/doc-site/usage/CHANGES.md index 5bdbfdcdc..d8f8a333a 100644 --- a/docs/doc-site/usage/CHANGES.md +++ b/docs/doc-site/usage/CHANGES.md @@ -8,7 +8,7 @@ weight: 15 **Key Changes:** - ✅ **Zero Dependencies**: Completely self-contained -- ✅ **New functions**: 58 additional assertions (43 generic + 15 reflection-based) +- ✅ **New functions**: 60 additional assertions (44 generic + 16 reflection-based) - ✅ **Performance**: ~10x for generic variants (from 1.2x to 81x, your mileage may vary) - ✅ **Breaking changes**: Requires go1.25, removed suites, mocks, http tooling, and deprecated functions. YAMLEq becomes optional (panics by default). @@ -42,7 +42,7 @@ See also a quick [migration guide](./MIGRATION.md). | Change | Origin | Description | |--------|--------|-------------| -| **Generic assertions** | Multiple upstream proposals | Added 38 type-safe assertion functions with `T` suffix across 10 domains | +| **Generic assertions** | Multiple upstream proposals | Added {{% siteparam "metrics.generics" %}} type-safe assertion functions with `T` suffix across {{% siteparam "metrics.domains" %}} domains | | **Zero dependencies** | Design goal | Internalized go-spew and difflib; removed all external dependencies | | **Optional YAML support** | Design goal | YAML assertions are now enabled via opt-in `enable/yaml` module | | **Colorized output** | [#1467], [#1480], [#1232], [#994] | Optional colorization via `enable/color` module with themes | diff --git a/docs/doc-site/usage/TRACKING.md b/docs/doc-site/usage/TRACKING.md index 804b405f1..0ec9253de 100644 --- a/docs/doc-site/usage/TRACKING.md +++ b/docs/doc-site/usage/TRACKING.md @@ -85,6 +85,7 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | [#1087] | Issue | Consistently assertion | ✅ Adapted | | [#1606] | PR | Consistently assertion | ✅ Adapted | | [#1848] | PR | Subset (garbled error message) | ✅ Adapted | +| [#1839] | PR | Number equality with symmetric role | ✅ Adapted | [#994]: https://github.com/stretchr/testify/pull/994 [#1232]: https://github.com/stretchr/testify/pull/1232 @@ -98,7 +99,9 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif [#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 [#1848]: https://github.com/stretchr/testify/pull/1848 +[#1876]: https://github.com/stretchr/testify/pull/1876 ### Superseded by Our Implementation @@ -114,11 +117,11 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | Reference | Type | Summary | Status | |-----------|------|---------|--------| +| [#1576] | Issue/PR | `EqualValues` assertion | 🔍 Monitoring [#1863]- Wrong equality when comparing float32 and float64| | [#1601] | Issue | `NoFieldIsZero` assertion | 🔍 Monitoring - Considering implementation | | [#1840] | Issue | JSON presence check without exact values | 🔍 Monitoring - Interesting for testing APIs with generated IDs | | [#1859] | Issue | Channel assertions | 🔍 Monitoring - aligned with synctest support | | [#1860] | Issue+PR | `ErrorAsType[E]` for Go 1.26+ - PR: [#1861] | 🔍 Monitoring - Interesting UX syntax | -| [#1863] | PR | Number equality with symmetric role | 🔍 Monitoring | ### Informational (Not Implemented) @@ -132,6 +135,7 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif [#1845]: https://github.com/stretchr/testify/pull/1845 [#1147]: https://github.com/stretchr/testify/issues/1147 [#1308]: https://github.com/stretchr/testify/pull/1308 +[#576]: https://github.com/stretchr/testify/pull/1576 [#1859]: https://github.com/stretchr/testify/pull/1859 [#1860]: https://github.com/stretchr/testify/pull/1860 [#1861]: https://github.com/stretchr/testify/pull/1861 @@ -142,11 +146,11 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | Category | Count | |----------|-------| -| **Implemented/Merged** | 24 | +| **Implemented/Merged** | 25 | | **Superseded** | 4 | | **Monitoring** | 5 | | **Informational** | 3 | -| **Total Processed** | 36 | +| **Total Processed** | 37 | **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/hack/doc-site/hugo/metrics.yaml b/hack/doc-site/hugo/metrics.yaml index 28f109835..3d6436a76 100644 --- a/hack/doc-site/hugo/metrics.yaml +++ b/hack/doc-site/hugo/metrics.yaml @@ -1,10 +1,10 @@ params: metrics: domains: 19 - functions: 135 - assertions: 131 - generics: 50 - nongeneric_assertions: 81 + functions: 137 + assertions: 133 + generics: 51 + nongeneric_assertions: 82 helpers: 4 others: 0 by_domain: @@ -40,7 +40,7 @@ params: count: 5 number: name: Number - count: 7 + count: 9 ordering: name: Ordering count: 10 @@ -65,6 +65,6 @@ params: yaml: name: Yaml count: 5 - package_variants: 424 - total_variants: 848 - total_functions: 858 + package_variants: 430 + total_variants: 860 + total_functions: 870 diff --git a/internal/assertions/number.go b/internal/assertions/number.go index fd549c419..d8fc6d6e6 100644 --- a/internal/assertions/number.go +++ b/internal/assertions/number.go @@ -222,6 +222,100 @@ func InEpsilonT[Number Measurable](t T, expected, actual Number, epsilon float64 return true } +// InEpsilonSymmetric asserts that 2 numbers are close, with a symmetric relative error. +// +// Unlike with [InEpsilon], both numbers play a symmetric role and the relative error is +// computed relative to the number with greatest amplitude. This mirrors the behavior of +// Python's [math.isclose] (with the relative-tolerance term only). +// +// See also [InEpsilon]. +// +// # Behavior with IEEE floating point arithmetic +// +// - NaN is matched only by a NaN, e.g. this works: [InEpsilonSymmetric]([math.NaN](), [math.Sqrt](-1), 0.0) +// - +Inf is matched only by a +Inf +// - -Inf is matched only by a -Inf +// +// Edge case: for very large integers that do not convert accurately to a float64 (e.g. uint64), prefer [InDelta]. +// +// Formula: +// +// - If x == 0 and y == 0: success +// - Otherwise fail if |x - y| > epsilon * max(|x|,|y|) +// +// # Usage +// +// assertions.InEpsilonSymmetric(t, 100.0, 101.0, 0.02) +// +// # Examples +// +// success: 100.0, 101.0, 0.02 +// failure: 100.0, 110.0, 0.05 +// +// [math.isclose]: https://docs.python.org/3/library/math.html#math.isclose +func InEpsilonSymmetric(t T, x, y any, epsilon float64, msgAndArgs ...any) bool { + // Domain: number + if h, ok := t.(H); ok { + h.Helper() + } + af, aok := toFloat(x) + bf, bok := toFloat(y) + if !aok || !bok { + return Fail(t, "Parameters must be numerical", msgAndArgs...) + } + + msg, skip, ok := checkDeltaEdgeCases(af, bf, epsilon) + if !ok { + return Fail(t, msg, msgAndArgs...) + } + if skip { + return true + } + + msg, ok = compareSymmetricRelativeError(af, bf, epsilon) + if !ok { + return Fail(t, msg, msgAndArgs...) + } + + return true +} + +// InEpsilonSymmetricT is the type-safe version of [InEpsilonSymmetric], comparing numbers of the same numerical type. +// +// See [InEpsilonSymmetric]. +// +// # Usage +// +// assertions.InEpsilonSymmetricT(t, 100.0, 101.0, 0.02) +// +// # Examples +// +// success: 100.0, 101.0, 0.02 +// failure: 100.0, 110.0, 0.05 +func InEpsilonSymmetricT[Number Measurable](t T, x, y Number, epsilon float64, msgAndArgs ...any) bool { + // Domain: number + if h, ok := t.(H); ok { + h.Helper() + } + + af := float64(x) + bf := float64(y) + msg, skip, ok := checkDeltaEdgeCases(af, bf, epsilon) + if !ok { + return Fail(t, msg, msgAndArgs...) + } + if skip { + return true + } + + msg, ok = compareSymmetricRelativeError(af, bf, epsilon) + if !ok { + return Fail(t, msg, msgAndArgs...) + } + + return true +} + // InDeltaSlice is the same as [InDelta], except it compares two slices. // // See [InDelta]. @@ -434,6 +528,21 @@ func compareRelativeError(expected, actual, epsilon float64) (msg string, ok boo return "", true } +func compareSymmetricRelativeError(a, b, epsilon float64) (msg string, ok bool) { + delta := math.Abs(a - b) + if delta == 0 { + return "", true + } + + amplitude := max(math.Abs(a), math.Abs(b)) + if delta > epsilon*amplitude { + return fmt.Sprintf("Symmetric relative error is too high: %#v (expected)\n"+ + " < %#v (actual)", epsilon, delta/amplitude), false + } + + return "", true +} + func toFloat(x any) (float64, bool) { var xf float64 xok := true diff --git a/internal/assertions/number_test.go b/internal/assertions/number_test.go index fb7a0cae5..895fe0064 100644 --- a/internal/assertions/number_test.go +++ b/internal/assertions/number_test.go @@ -92,6 +92,15 @@ func TestNumberInEpsilonSlice(t *testing.T) { } } +func TestNumberInEpsilonSymmetric(t *testing.T) { + t.Parallel() + + // run all test cases with both InEpsilonSymmetric and InEpsilonSymmetricT + for tc := range inEpsilonSymmetricCases() { + t.Run(tc.name, tc.test) + } +} + func TestNumberErrorMessages(t *testing.T) { t.Parallel() @@ -585,6 +594,152 @@ func testEpsilonT[Number Measurable](expected, actual Number, epsilon float64, s } } +// ======================================= +// Test NumberInEpsilonSymmetric variants +// ======================================= + +func inEpsilonSymmetricCases() iter.Seq[genericTestCase] { + return slices.Values([]genericTestCase{ + // Asymmetric-vs-symmetric — these are the cases that justify InEpsilonSymmetric. + // With InEpsilon the threshold is epsilon * |expected|; with InEpsilonSymmetric + // it is epsilon * max(|x|, |y|). When |y| > |x|, InEpsilonSymmetric is more lenient. + // + // (10, 14, 0.3): InEpsilon fails (4 > 3), InEpsilonSymmetric passes (4 <= 4.2) + // (0.1, 0.14, 0.3): same ratio, scaled down + {"asymmetric/larger-second-arg", testAllSymmetric(10.0, 14.0, 0.3, true)}, + {"asymmetric/larger-second-arg-small", testAllSymmetric(0.1, 0.14, 0.3, true)}, + // And the symmetric counterpart: swapping arguments doesn't change the result. + {"asymmetric/larger-first-arg", testAllSymmetric(14.0, 10.0, 0.3, true)}, + {"asymmetric/larger-first-arg-small", testAllSymmetric(0.14, 0.1, 0.3, true)}, + // Just below the boundary — both must still fail. + {"asymmetric/just-below-boundary", testAllSymmetric(10.0, 14.0, 0.28, false)}, + + // Simple input cases + {"simple/1pct-error-within-2pct-epsilon", testAllSymmetric(100.0, 101.0, 0.02, true)}, + {"simple/5pct-error-exceeds-2pct-epsilon", testAllSymmetric(100.0, 105.0, 0.02, false)}, + {"simple/exact-match-zero-epsilon", testAllSymmetric(100.0, 100.0, 0.0, true)}, + + // Edge cases - NaN + {"edge/nan-for-actual", testAllSymmetric(42.0, math.NaN(), 0.01, false)}, + {"edge/nan-for-expected", testAllSymmetric(math.NaN(), 42.0, 0.01, false)}, + {"edge/nan-for-both", testAllSymmetric(math.NaN(), math.NaN(), 0.01, true)}, + + // Edge cases - both zero (passes whatever epsilon) + {"edge/both-zero", testAllSymmetric(0.0, 0.0, 0.01, true)}, + {"edge/both-zero-zero-epsilon", testAllSymmetric(0.0, 0.0, 0.0, true)}, + // One side is zero: amplitude == |non-zero side|, so 1 > epsilon must hold. + {"edge/zero-vs-nonzero-fails", testAllSymmetric(0.0, 0.5, 0.5, false)}, // 0.5 > 0.5*0.5 = 0.25 + {"edge/zero-vs-nonzero-large-epsilon", testAllSymmetric(0.0, 0.5, 1.0, true)}, + + // All numeric types - basic success cases + {"int/success", testAllSymmetric(int(100), int(101), 0.02, true)}, + {"int8/success", testAllSymmetric(int8(100), int8(101), 0.02, true)}, + {"int16/success", testAllSymmetric(int16(100), int16(101), 0.02, true)}, + {"int32/success", testAllSymmetric(int32(100), int32(101), 0.02, true)}, + {"int64/success", testAllSymmetric(int64(100), int64(101), 0.02, true)}, + {"uint/success", testAllSymmetric(uint(100), uint(101), 0.02, true)}, + {"uint8/success", testAllSymmetric(uint8(100), uint8(101), 0.02, true)}, + {"uint16/success", testAllSymmetric(uint16(100), uint16(101), 0.02, true)}, + {"uint32/success", testAllSymmetric(uint32(100), uint32(101), 0.02, true)}, + {"uint64/success", testAllSymmetric(uint64(100), uint64(101), 0.02, true)}, + {"float32/success", testAllSymmetric(float32(100.0), float32(101.0), 0.02, true)}, + {"float64/success", testAllSymmetric(100.0, 101.0, 0.02, true)}, + + // Basic failure cases + {"int/failure", testAllSymmetric(int(100), int(110), 0.05, false)}, + {"uint/failure", testAllSymmetric(uint(100), uint(110), 0.05, false)}, + {"float64/failure", testAllSymmetric(100.0, 110.0, 0.05, false)}, + + // Negative numbers + {"int/negative", testAllSymmetric(int(-100), int(-101), 0.02, true)}, + {"int/negative-fail", testAllSymmetric(int(-100), int(-110), 0.05, false)}, + {"float64/negative", testAllSymmetric(-100.0, -101.0, 0.02, true)}, + + // Mixed positive/negative — amplitude is max(|x|, |y|), so for (100, -100) + // delta = 200 and threshold = 100 * epsilon. Boundary is epsilon == 2.0. + {"mixed/at-boundary", testAllSymmetric(100.0, -100.0, 2.0, true)}, + {"mixed/just-below", testAllSymmetric(100.0, -100.0, 1.99, false)}, + + // Float32 NaN cases + {"float32/both-nan", testAllSymmetric(float32(math.NaN()), float32(math.NaN()), 0.01, true)}, + {"float32/expected-nan", testAllSymmetric(float32(math.NaN()), float32(42.0), 0.01, false)}, + {"float32/actual-nan", testAllSymmetric(float32(42.0), float32(math.NaN()), 0.01, false)}, + + // Float32 +Inf cases + {"float32/both-plus-inf", testAllSymmetric(float32(math.Inf(1)), float32(math.Inf(1)), 0.01, true)}, + {"float32/plus-inf-vs-minus-inf", testAllSymmetric(float32(math.Inf(1)), float32(math.Inf(-1)), 0.01, false)}, + {"float32/plus-inf-vs-finite", testAllSymmetric(float32(math.Inf(1)), float32(100.0), 0.01, false)}, + {"float32/finite-vs-plus-inf", testAllSymmetric(float32(100.0), float32(math.Inf(1)), 0.01, false)}, + + // Float64 +Inf cases + {"float64/both-plus-inf", testAllSymmetric(math.Inf(1), math.Inf(1), 0.01, true)}, + {"float64/plus-inf-vs-minus-inf", testAllSymmetric(math.Inf(1), math.Inf(-1), 0.01, false)}, + {"float64/plus-inf-vs-finite", testAllSymmetric(math.Inf(1), 100.0, 0.01, false)}, + + // Float64 -Inf cases + {"float64/both-minus-inf", testAllSymmetric(math.Inf(-1), math.Inf(-1), 0.01, true)}, + {"float64/minus-inf-vs-finite", testAllSymmetric(math.Inf(-1), 100.0, 0.01, false)}, + + // Epsilon validation + {"epsilon-negative", testAllSymmetric(100.0, 100.0, -0.01, false)}, + {"epsilon-nan", testAllSymmetric(100.0, 100.0, math.NaN(), false)}, + {"epsilon-plus-inf", testAllSymmetric(100.0, 100.0, math.Inf(1), false)}, + {"epsilon-minus-inf", testAllSymmetric(100.0, 100.0, math.Inf(-1), false)}, + + // Large values + {"int64/large-values", testAllSymmetric(int64(1000000000), int64(1010000000), 0.02, true)}, + {"uint64/large-values", testAllSymmetric(uint64(1000000000), uint64(1010000000), 0.02, true)}, + {"float64/large-values", testAllSymmetric(1e15, 1.01e15, 0.02, true)}, + + // time.Duration (covers toFloat time.Duration case in InEpsilonSymmetric) + {"duration/success", testAllSymmetric(100*time.Millisecond, 101*time.Millisecond, 0.02, true)}, + {"duration/failure", testAllSymmetric(100*time.Millisecond, 110*time.Millisecond, 0.05, false)}, + + // custom float type (covers toFloat reflect conversion in InEpsilonSymmetric) + {"custom-float/success", testAllSymmetric(customFloat(100.0), customFloat(101.0), 0.02, true)}, + {"custom-float/failure", testAllSymmetric(customFloat(100.0), customFloat(110.0), 0.05, false)}, + }) +} + +// testAllSymmetric tests both InEpsilonSymmetric and InEpsilonSymmetricT with the same input. +func testAllSymmetric[Number Measurable](x, y Number, epsilon float64, shouldPass bool) func(*testing.T) { + return func(t *testing.T) { + t.Parallel() + + if shouldPass { + t.Run("should pass", func(t *testing.T) { + t.Run("with InEpsilonSymmetric", testSymmetric(x, y, epsilon, true)) + t.Run("with InEpsilonSymmetricT", testSymmetricT(x, y, epsilon, true)) + }) + } else { + t.Run("should fail", func(t *testing.T) { + t.Run("with InEpsilonSymmetric", testSymmetric(x, y, epsilon, false)) + t.Run("with InEpsilonSymmetricT", testSymmetricT(x, y, epsilon, false)) + }) + } + } +} + +func testSymmetric[Number Measurable](x, y Number, epsilon float64, shouldPass bool) func(*testing.T) { + return func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := InEpsilonSymmetric(mock, x, y, epsilon) + shouldPassOrFail(t, mock, result, shouldPass) + } +} + +func testSymmetricT[Number Measurable](x, y Number, epsilon float64, shouldPass bool) func(*testing.T) { + return func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := InEpsilonSymmetricT(mock, x, y, epsilon) + shouldPassOrFail(t, mock, result, shouldPass) + } +} + // Helper functions and test data for InDeltaSlice func deltaSliceCases() iter.Seq[genericTestCase] { @@ -733,5 +888,15 @@ func numberFailCases() iter.Seq[failCase] { assertion: func(t T) bool { return InEpsilon(t, "", 0.5, 0.1) }, wantContains: []string{"Parameters must be numerical"}, }, + { + name: "InEpsilonSymmetric/non-numeric-types", + assertion: func(t T) bool { return InEpsilonSymmetric(t, "", 0.5, 0.1) }, + wantContains: []string{"Parameters must be numerical"}, + }, + { + name: "InEpsilonSymmetricT/relative-error", + assertion: func(t T) bool { return InEpsilonSymmetricT(t, 100.0, 110.0, 0.05) }, + wantContains: []string{"Symmetric relative error is too high"}, + }, }) } diff --git a/require/require_assertions.go b/require/require_assertions.go index 5dff35585..368c4b372 100644 --- a/require/require_assertions.go +++ b/require/require_assertions.go @@ -1385,6 +1385,75 @@ func InEpsilonSlice(t T, expected any, actual any, epsilon float64, msgAndArgs . t.FailNow() } +// InEpsilonSymmetric asserts that 2 numbers are close, with a symmetric relative error. +// +// Unlike with [InEpsilon], both numbers play a symmetric role and the relative error is +// computed relative to the number with greatest amplitude. This mirrors the behavior of +// Python's [math.isclose] (with the relative-tolerance term only). +// +// See also [InEpsilon]. +// +// # Behavior with IEEE floating point arithmetic +// +// - NaN is matched only by a NaN, e.g. this works: [InEpsilonSymmetric]([math.NaN](), [math.Sqrt](-1), 0.0) +// - +Inf is matched only by a +Inf +// - -Inf is matched only by a -Inf +// +// Edge case: for very large integers that do not convert accurately to a float64 (e.g. uint64), prefer [InDelta]. +// +// Formula: +// +// - If x == 0 and y == 0: success +// - Otherwise fail if |x - y| > epsilon * max(|x|,|y|) +// +// # Usage +// +// assertions.InEpsilonSymmetric(t, 100.0, 101.0, 0.02) +// +// # Examples +// +// success: 100.0, 101.0, 0.02 +// failure: 100.0, 110.0, 0.05 +// +// Upon failure, the test [T] is marked as failed and stops execution. +// +// [math.isclose]: https://docs.python.org/3/library/math.html#math.isclose +func InEpsilonSymmetric(t T, x any, y any, epsilon float64, msgAndArgs ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.InEpsilonSymmetric(t, x, y, epsilon, msgAndArgs...) { + return + } + + t.FailNow() +} + +// InEpsilonSymmetricT is the type-safe version of [InEpsilonSymmetric], comparing numbers of the same numerical type. +// +// See [InEpsilonSymmetric]. +// +// # Usage +// +// assertions.InEpsilonSymmetricT(t, 100.0, 101.0, 0.02) +// +// # Examples +// +// success: 100.0, 101.0, 0.02 +// failure: 100.0, 110.0, 0.05 +// +// Upon failure, the test [T] is marked as failed and stops execution. +func InEpsilonSymmetricT[Number Measurable](t T, x Number, y Number, epsilon float64, msgAndArgs ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.InEpsilonSymmetricT[Number](t, x, y, epsilon, msgAndArgs...) { + return + } + + t.FailNow() +} + // InEpsilonT asserts that expected and actual have a relative error less than epsilon. // // When expected is zero, epsilon is interpreted as an absolute error threshold, diff --git a/require/require_assertions_test.go b/require/require_assertions_test.go index 8d1b25d0b..e0a8be4b8 100644 --- a/require/require_assertions_test.go +++ b/require/require_assertions_test.go @@ -1047,6 +1047,52 @@ func TestInEpsilonSlice(t *testing.T) { }) } +func TestInEpsilonSymmetric(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + InEpsilonSymmetric(mock, 100.0, 101.0, 0.02) + // require functions don't return a value + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + InEpsilonSymmetric(mock, 100.0, 110.0, 0.05) + // require functions don't return a value + if !mock.failed { + t.Error("InEpsilonSymmetric should call FailNow()") + } + }) +} + +func TestInEpsilonSymmetricT(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + InEpsilonSymmetricT(mock, 100.0, 101.0, 0.02) + // require functions don't return a value + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + InEpsilonSymmetricT(mock, 100.0, 110.0, 0.05) + // require functions don't return a value + if !mock.failed { + t.Error("InEpsilonSymmetricT should call FailNow()") + } + }) +} + func TestInEpsilonT(t *testing.T) { t.Parallel() diff --git a/require/require_examples_test.go b/require/require_examples_test.go index 8e061279a..54168f7f5 100644 --- a/require/require_examples_test.go +++ b/require/require_examples_test.go @@ -381,6 +381,22 @@ func ExampleInEpsilonSlice() { // Output: passed } +func ExampleInEpsilonSymmetric() { + t := new(testing.T) // should come from testing, e.g. func TestInEpsilonSymmetric(t *testing.T) + require.InEpsilonSymmetric(t, 100.0, 101.0, 0.02) + fmt.Println("passed") + + // Output: passed +} + +func ExampleInEpsilonSymmetricT() { + t := new(testing.T) // should come from testing, e.g. func TestInEpsilonSymmetricT(t *testing.T) + require.InEpsilonSymmetricT(t, 100.0, 101.0, 0.02) + fmt.Println("passed") + + // Output: passed +} + func ExampleInEpsilonT() { t := new(testing.T) // should come from testing, e.g. func TestInEpsilonT(t *testing.T) require.InEpsilonT(t, 100.0, 101.0, 0.02) diff --git a/require/require_format.go b/require/require_format.go index 30cace309..37aec6e86 100644 --- a/require/require_format.go +++ b/require/require_format.go @@ -641,6 +641,34 @@ func InEpsilonSlicef(t T, expected any, actual any, epsilon float64, msg string, t.FailNow() } +// InEpsilonSymmetricf is the same as [InEpsilonSymmetric], but it accepts a format string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and stops execution. +func InEpsilonSymmetricf(t T, x any, y any, epsilon float64, msg string, args ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.InEpsilonSymmetric(t, x, y, epsilon, forwardArgs(msg, args)) { + return + } + + t.FailNow() +} + +// InEpsilonSymmetricTf is the same as [InEpsilonSymmetricT], but it accepts a format string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and stops execution. +func InEpsilonSymmetricTf[Number Measurable](t T, x Number, y Number, epsilon float64, msg string, args ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.InEpsilonSymmetricT[Number](t, x, y, epsilon, forwardArgs(msg, args)) { + return + } + + t.FailNow() +} + // InEpsilonTf is the same as [InEpsilonT], but it accepts a format string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and stops execution. diff --git a/require/require_format_test.go b/require/require_format_test.go index 11b92a772..1be223ddb 100644 --- a/require/require_format_test.go +++ b/require/require_format_test.go @@ -1047,6 +1047,52 @@ func TestInEpsilonSlicef(t *testing.T) { }) } +func TestInEpsilonSymmetricf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + InEpsilonSymmetricf(mock, 100.0, 101.0, 0.02, "test message") + // require functions don't return a value + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + InEpsilonSymmetricf(mock, 100.0, 110.0, 0.05, "test message") + // require functions don't return a value + if !mock.failed { + t.Error("InEpsilonSymmetricf should call FailNow()") + } + }) +} + +func TestInEpsilonSymmetricTf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + InEpsilonSymmetricTf(mock, 100.0, 101.0, 0.02, "test message") + // require functions don't return a value + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + InEpsilonSymmetricTf(mock, 100.0, 110.0, 0.05, "test message") + // require functions don't return a value + if !mock.failed { + t.Error("InEpsilonSymmetricTf should call FailNow()") + } + }) +} + func TestInEpsilonTf(t *testing.T) { t.Parallel() diff --git a/require/require_forward.go b/require/require_forward.go index be0b2a16c..dc7cc8fbd 100644 --- a/require/require_forward.go +++ b/require/require_forward.go @@ -1030,6 +1030,34 @@ func (a *Assertions) InEpsilonSlicef(expected any, actual any, epsilon float64, a.T.FailNow() } +// InEpsilonSymmetric is the same as [InEpsilonSymmetric], as a method rather than a package-level function. +// +// Upon failure, the test [T] is marked as failed and stops execution. +func (a *Assertions) InEpsilonSymmetric(x any, y any, epsilon float64, msgAndArgs ...any) { + if h, ok := a.T.(H); ok { + h.Helper() + } + if assertions.InEpsilonSymmetric(a.T, x, y, epsilon, msgAndArgs...) { + return + } + + a.T.FailNow() +} + +// InEpsilonSymmetricf is the same as [Assertions.InEpsilonSymmetric], but it accepts a format string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and stops execution. +func (a *Assertions) InEpsilonSymmetricf(x any, y any, epsilon float64, msg string, args ...any) { + if h, ok := a.T.(H); ok { + h.Helper() + } + if assertions.InEpsilonSymmetric(a.T, x, y, epsilon, forwardArgs(msg, args)) { + return + } + + a.T.FailNow() +} + // IsDecreasing is the same as [IsDecreasing], as a method rather than a package-level function. // // Upon failure, the test [T] is marked as failed and stops execution. diff --git a/require/require_forward_test.go b/require/require_forward_test.go index f1714d94a..6dbb35eaf 100644 --- a/require/require_forward_test.go +++ b/require/require_forward_test.go @@ -898,6 +898,31 @@ func TestAssertionsInEpsilonSlice(t *testing.T) { }) } +func TestAssertionsInEpsilonSymmetric(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + a := New(mock) + a.InEpsilonSymmetric(100.0, 101.0, 0.02) + // require functions don't return a value + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + a := New(mock) + a.InEpsilonSymmetric(100.0, 110.0, 0.05) + // require functions don't return a value + if !mock.failed { + t.Error("Assertions.InEpsilonSymmetric should call FailNow()") + } + }) +} + func TestAssertionsIsDecreasing(t *testing.T) { t.Parallel() @@ -2865,6 +2890,31 @@ func TestAssertionsInEpsilonSlicef(t *testing.T) { }) } +func TestAssertionsInEpsilonSymmetricf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + a := New(mock) + a.InEpsilonSymmetricf(100.0, 101.0, 0.02, "test message") + // require functions don't return a value + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + a := New(mock) + a.InEpsilonSymmetricf(100.0, 110.0, 0.05, "test message") + // require functions don't return a value + if !mock.failed { + t.Error("Assertions.InEpsilonSymmetricf should call FailNow()") + } + }) +} + func TestAssertionsIsDecreasingf(t *testing.T) { t.Parallel()