From 03100c3f6315f8defc51970d24789cae67b55c02 Mon Sep 17 00:00:00 2001 From: x0Lazarus <113273587+x0Lazarus@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:18:41 -0700 Subject: [PATCH] Compare JSON numbers without rounding away differences --- assert/assertions.go | 18 ++++++++ assert/json.go | 64 +++++++++++++++++++++++++++ assert/json_precision_test.go | 83 +++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 assert/json.go create mode 100644 assert/json_precision_test.go diff --git a/assert/assertions.go b/assert/assertions.go index 166f63726..9ba722810 100644 --- a/assert/assertions.go +++ b/assert/assertions.go @@ -1878,6 +1878,24 @@ func JSONEq(t TestingT, expected string, actual string, msgAndArgs ...interface{ return Fail(t, fmt.Sprintf("Input ('%s') needs to be valid json.\nJSON parsing error: '%s'", actual, err.Error()), msgAndArgs...) } + if ObjectsAreEqual(expectedJSONAsInterface, actualJSONAsInterface) { + // Float64 conversion can hide differences in large integers and precise decimals. + var err error + expectedJSONAsInterface, err = decodeJSONNumbers(expected) + if err != nil { + return Fail(t, fmt.Sprintf("Expected value ('%s') is not valid json.\nJSON parsing error: '%s'", expected, err.Error()), msgAndArgs...) + } + actualJSONAsInterface, err = decodeJSONNumbers(actual) + if err != nil { + return Fail(t, fmt.Sprintf("Input ('%s') needs to be valid json.\nJSON parsing error: '%s'", actual, err.Error()), msgAndArgs...) + } + if ObjectsAreEqual(expectedJSONAsInterface, actualJSONAsInterface) { + return true + } + expectedJSONAsInterface = normalizeJSONNumbers(expectedJSONAsInterface) + actualJSONAsInterface = normalizeJSONNumbers(actualJSONAsInterface) + } + return Equal(t, expectedJSONAsInterface, actualJSONAsInterface, msgAndArgs...) } diff --git a/assert/json.go b/assert/json.go new file mode 100644 index 000000000..7405b95f7 --- /dev/null +++ b/assert/json.go @@ -0,0 +1,64 @@ +package assert + +import ( + "encoding/json" + "math/big" + "strings" +) + +func decodeJSONNumbers(text string) (interface{}, error) { + decoder := json.NewDecoder(strings.NewReader(text)) + decoder.UseNumber() + var value interface{} + if err := decoder.Decode(&value); err != nil { + return nil, err + } + return value, nil +} + +func normalizeJSONNumbers(value interface{}) interface{} { + switch value := value.(type) { + case json.Number: + return normalizeJSONNumber(value) + case []interface{}: + for i, element := range value { + value[i] = normalizeJSONNumbers(element) + } + case map[string]interface{}: + for key, element := range value { + value[key] = normalizeJSONNumbers(element) + } + } + return value +} + +func normalizeJSONNumber(value json.Number) json.Number { + number := string(value) + negative := strings.HasPrefix(number, "-") + if negative { + number = number[1:] + } + exponentText := "0" + if i := strings.IndexAny(number, "eE"); i >= 0 { + exponentText = number[i+1:] + number = number[:i] + } + var fraction int + if i := strings.IndexByte(number, '.'); i >= 0 { + fraction = len(number) - i - 1 + number = number[:i] + number[i+1:] + } + number = strings.TrimLeft(number, "0") + if number == "" { + return "0" + } + digits := strings.TrimRight(number, "0") + // Keep the exponent separate so tiny numbers need no enormous power of ten. + var exponent big.Int + exponent.SetString(exponentText, 10) + exponent.Add(&exponent, big.NewInt(int64(len(number)-len(digits)-fraction))) + if negative { + digits = "-" + digits + } + return json.Number(digits + "e" + exponent.String()) +} diff --git a/assert/json_precision_test.go b/assert/json_precision_test.go new file mode 100644 index 000000000..629229ffd --- /dev/null +++ b/assert/json_precision_test.go @@ -0,0 +1,83 @@ +package assert + +import ( + "strings" + "testing" +) + +func TestJSONEqNumericPrecision(t *testing.T) { + cases := []struct { + name, expected, actual string + }{ + {"large integers", `{"id":9007199254740992}`, `{"id":9007199254740993}`}, + {"negative integers", `[-9007199254740992]`, `[-9007199254740993]`}, + {"int64 limits", `9223372036854775806`, `9223372036854775807`}, + {"decimal digits", `0.1`, `0.10000000000000001`}, + {"exponent digits", `1e0`, `1.0000000000000001e0`}, + {"underflow", `1e-400`, `0`}, + {"negative underflow", `-1e-400`, `0`}, + {"rounded subnormal", `4e-324`, `5e-324`}, + {"nested values", `{"items":[{"amount":0.1}]}`, `{"items":[{"amount":0.10000000000000001}]}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mockT := &mockTestingT{} + if JSONEq(mockT, tc.expected, tc.actual) { + t.Errorf("different JSON numbers compared equal: %s and %s", tc.expected, tc.actual) + } + if !mockT.Failed() { + t.Error("missing assertion failure message") + } + }) + } +} + +func TestJSONEqNumericEquivalence(t *testing.T) { + cases := []struct { + name, expected, actual string + }{ + {"integer and decimal", `1`, `1.0`}, + {"integer and exponent", `1000`, `1e3`}, + {"fraction zeroes", `0.00000100`, `1e-6`}, + {"negative value", `-120.00100`, `-120001e-3`}, + {"signed zero", `-0`, `0.0`}, + {"zero exponent", `-0e99999999999999999999999999999`, `0`}, + {"tiny exponent", `1e-99999999999999999999999999999`, `10e-100000000000000000000000000000`}, + {"large integer", `9007199254740993`, `9007199254740993.0`}, + {"nested key order", `{"a":1,"b":[2.00]}`, `{"b":[2e0],"a":1.0}`}, + {"duplicate key", `{"value":1,"value":2}`, `{"value":2.0}`}, + {"same bytes", `{"value":9007199254740993}`, `{"value":9007199254740993}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mockT := &mockTestingT{} + if !JSONEq(mockT, tc.expected, tc.actual) { + t.Errorf("equivalent JSON numbers failed comparison: %s", mockT.errorString()) + } + }) + } +} + +func TestJSONEqPreservesValidation(t *testing.T) { + cases := []struct { + name, expected, actual, diagnostic string + }{ + {"expected overflow", `1e1000`, `0`, "Expected value ('1e1000') is not valid json."}, + {"actual overflow", `0`, `1e1000`, "Input ('1e1000') needs to be valid json."}, + {"same overflow", `1e1000`, `1e1000`, "Expected value ('1e1000') is not valid json."}, + {"expected invalid", `01`, `1`, "Expected value ('01') is not valid json."}, + {"actual invalid", `1`, `1 2`, "Input ('1 2') needs to be valid json."}, + {"same invalid", `1 2`, `1 2`, "Expected value ('1 2') is not valid json."}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mockT := &mockTestingT{} + if JSONEq(mockT, tc.expected, tc.actual) { + t.Fatal("invalid input compared equal") + } + if !strings.Contains(mockT.errorString(), tc.diagnostic) { + t.Errorf("missing %q in failure message: %s", tc.diagnostic, mockT.errorString()) + } + }) + } +}