diff --git a/e2e/report_test.go b/e2e/report_test.go index 334662dc7..118cddb02 100644 --- a/e2e/report_test.go +++ b/e2e/report_test.go @@ -69,6 +69,10 @@ func TestE2EReport_Values(t *testing.T) { w.Header().Add("Content-Type", "application/json") _, _ = w.Write([]byte("[111, 222, 444, 333]")) }) + mux.HandleFunc("/slice/float", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + _, _ = w.Write([]byte("[12.34, 0.056]")) + }) server := httptest.NewServer(mux) defer server.Close() @@ -219,6 +223,35 @@ func TestE2EReport_Values(t *testing.T) { t, trimmed, "333333 444444", "missing Expected", ) }) + + t.Run("slice of floats", func(t *testing.T) { + reporter := &recordingReporter{} + + e := httpexpect.WithConfig(httpexpect.Config{ + BaseURL: server.URL, + Reporter: reporter, + Formatter: &httpexpect.DefaultFormatter{ + FloatFormat: httpexpect.FloatFormatScientific, + ColorMode: httpexpect.ColorModeAlways, + }, + }) + + e.GET("/slice/float"). + Expect(). + JSON(). + Array(). + IsEmpty() + t.Logf("%s", reporter.recorded) + + assert.Contains( + t, reporter.recorded, "expected: empty array", + "missing Errors", + ) + assert.Contains( + t, reporter.recorded, "[\n 1.234e+01,\n 5.6e-02\n ]", + "missing Actual", + ) + }) } func TestE2EReport_Path(t *testing.T) { diff --git a/formatter.go b/formatter.go index 02985f31c..871334e9b 100644 --- a/formatter.go +++ b/formatter.go @@ -5,10 +5,13 @@ import ( "encoding/json" "flag" "fmt" + "io" "math" + "math/big" "net/http/httputil" "os" "path/filepath" + "reflect" "regexp" "strconv" "strings" @@ -622,6 +625,7 @@ func (f *DefaultFormatter) formatValue(value interface{}) string { return ss } } + value, _ = f.convertNumber(value) if b, err := json.MarshalIndent(value, "", defaultIndent); err == nil { return string(b) } @@ -633,6 +637,95 @@ func (f *DefaultFormatter) formatValue(value interface{}) string { return sq.Sdump(value) } +func (f *DefaultFormatter) convertNumber(value interface{}) (interface{}, bool) { + if fm := newFormatNumber(value, f); fm != nil { + return fm, true + } + + rv := reflect.ValueOf(value) + + switch rv.Kind() { //nolint + case reflect.Slice: + sl := reflect.MakeSlice(reflect.TypeOf([]interface{}{}), rv.Len(), rv.Cap()) + updated := false + + for i := 0; i < rv.Len(); i++ { + converted, changed := f.convertNumber(rv.Index(i).Interface()) + if changed { + sl.Index(i).Set(reflect.ValueOf(converted)) + updated = true + } + } + + return sl.Interface(), updated + + case reflect.Map: + keyType := reflect.TypeOf(value).Key() + mapType := reflect.MapOf(keyType, reflect.TypeOf((*interface{})(nil)).Elem()) + mp := reflect.MakeMap(mapType) + updated := false + + iter := rv.MapRange() + for iter.Next() { + converted, changed := f.convertNumber(iter.Value().Interface()) + if changed { + mp.SetMapIndex(iter.Key(), reflect.ValueOf(converted)) + updated = true + } + } + + return mp.Interface(), updated + + default: + return value, false + } +} + +type formatNumber struct { + value *big.Float + f *DefaultFormatter +} + +func newFormatNumber(value interface{}, f *DefaultFormatter) *formatNumber { + if flt := extractBigFloat(value); flt != nil { + return &formatNumber{ + value: flt, + f: f, + } + } + return nil +} + +func (fn formatNumber) MarshalJSON() ([]byte, error) { + if flt, accuracy := fn.value.Float32(); accuracy == big.Exact { + return []byte(fn.f.formatFloatValue(float64(flt), 32)), nil + } + + if flt, accuracy := fn.value.Float64(); accuracy == big.Exact { + return []byte(fn.f.formatFloatValue(flt, 64)), nil + } + + return fn.value.MarshalText() +} + +func (fn formatNumber) LitterDump(w io.Writer) { + if flt, accuracy := fn.value.Float32(); accuracy == big.Exact { + s := fn.f.reformatNumber(fn.f.formatFloatValue(float64(flt), 32)) + _, _ = w.Write([]byte(s)) + return + } + + if flt, accuracy := fn.value.Float64(); accuracy == big.Exact { + s := fn.f.reformatNumber(fn.f.formatFloatValue(flt, 64)) + _, _ = w.Write([]byte(s)) + return + } + + if bytes, err := fn.value.MarshalText(); err == nil { + _, _ = w.Write(bytes) + } +} + func (f *DefaultFormatter) formatFloatValue(value float64, bits int) string { switch f.FloatFormat { case FloatFormatAuto: @@ -855,6 +948,23 @@ func extractFloat64(value interface{}) *float64 { } } +func extractBigFloat(value interface{}) *big.Float { + if flt, err := strconv.ParseFloat(fmt.Sprint(value), 64); err == nil { + return big.NewFloat(flt) + } + + switch value := value.(type) { + case *big.Float: + return value + + case json.Number: + if flt, err := value.Float64(); err == nil { + return big.NewFloat(flt) + } + } + return nil +} + func exctractRange(value interface{}) *AssertionRange { switch rng := value.(type) { case AssertionRange: @@ -1081,8 +1191,9 @@ var defaultTemplateFuncs = template.FuncMap{ } var parsedInput interface{} - err := json.Unmarshal([]byte(input), &parsedInput) - if err != nil { + decoder := json.NewDecoder(strings.NewReader(input)) + decoder.UseNumber() + if err := decoder.Decode(&parsedInput); err != nil { return color.New(fallbackColor).Sprint(input) } diff --git a/formatter_test.go b/formatter_test.go index a78d1d255..53d525f21 100644 --- a/formatter_test.go +++ b/formatter_test.go @@ -1,7 +1,9 @@ package httpexpect import ( + "encoding/json" "fmt" + "math/big" "strings" "testing" "time" @@ -696,6 +698,176 @@ func TestFormatter_FloatFormat(t *testing.T) { value: int(12345678), wantText: "12_345_678", }, + // slice of floats + { + name: "slice of float auto", + format: FloatFormatAuto, + value: []float32{1.234, 0.0056, 78000}, + wantText: "[\n 1.234,\n 0.0056,\n 78000\n]", + }, + { + name: "slice of float decimal", + format: FloatFormatDecimal, + value: []float32{1.234, 0.0056, 78000}, + wantText: "[\n 1.234,\n 0.0056,\n 78000\n]", + }, + { + name: "slice of float scientific", + format: FloatFormatScientific, + value: []float32{1.234, 0.0056, 78000}, + wantText: "[\n 1.234e+00,\n 5.6e-03,\n 7.8e+04\n]", + }, + // slice of json.Number + { + name: "slice of json.Number auto", + format: FloatFormatAuto, + value: []json.Number{"12.34", ".0056", "789"}, + wantText: "[\n 12.34,\n 0.0056,\n 789\n]", + }, + { + name: "slice of json.Number decimal", + format: FloatFormatDecimal, + value: []json.Number{"12.34", ".0056", "789"}, + wantText: "[\n 12.34,\n 0.0056,\n 789\n]", + }, + { + name: "slice of json.Number scientific", + format: FloatFormatScientific, + value: []json.Number{"12.34", ".0056", "789"}, + wantText: "[\n 1.234e+01,\n 5.6e-03,\n 7.89e+02\n]", + }, + // slice of big.Float + { + name: "slice of big.Float auto", + format: FloatFormatAuto, + value: []*big.Float{big.NewFloat(1234.5678), big.NewFloat(0.000234)}, + wantText: "[\n 1234.5678,\n 0.000234\n]", + }, + { + name: "slice of big.Float decimal", + format: FloatFormatDecimal, + value: []*big.Float{big.NewFloat(1234.5678), big.NewFloat(0.000234)}, + wantText: "[\n 1234.5678,\n 0.000234\n]", + }, + { + name: "slice of big.Float scientific", + format: FloatFormatScientific, + value: []*big.Float{big.NewFloat(1234.5678), big.NewFloat(0.000234)}, + wantText: "[\n 1.2345678e+03,\n 2.34e-04\n]", + }, + // slice of slice + { + name: "slice of slice auto", + format: FloatFormatAuto, + value: []interface{}{[]float64{.01, 20}, []json.Number{"23.45"}, 10}, + wantText: "[\n [\n 0.01,\n 20\n ],\n [\n 23.45\n ],\n 10\n]", + }, + { + name: "slice of slice decimal", + format: FloatFormatDecimal, + value: []interface{}{[]float64{.01, 20}, []json.Number{"23.45"}, 10}, + wantText: "[\n [\n 0.01,\n 20\n ],\n [\n 23.45\n ],\n 10\n]", + }, + { + name: "slice of slice scientific", + format: FloatFormatScientific, + value: []interface{}{[]float64{.01, 20}, []json.Number{"23.45"}, 10}, + wantText: "[\n [\n 1e-02,\n 2e+01\n ],\n [\n 2.345e+01\n ],\n 1e+01\n]", + }, + // map of floats + { + name: "map of float auto", + format: FloatFormatAuto, + value: map[string]float32{"a": 123.45, "b": 0.00678}, + wantText: "{\n \"a\": 123.45,\n \"b\": 0.00678\n}", + }, + { + name: "map of float decimal", + format: FloatFormatDecimal, + value: map[string]float32{"a": 123.45, "b": 0.00678}, + wantText: "{\n \"a\": 123.45,\n \"b\": 0.00678\n}", + }, + { + name: "map of float scientific", + format: FloatFormatScientific, + value: map[string]float32{"a": 123.45, "b": 0.00678}, + wantText: "{\n \"a\": 1.2345e+02,\n \"b\": 6.78e-03\n}", + }, + // map of json.Number + { + name: "map of json.Number auto", + format: FloatFormatAuto, + value: map[int]json.Number{1: "0.123", 2: "45.67", 3: "100"}, + wantText: "{\n \"1\": 0.123,\n \"2\": 45.67,\n \"3\": 100\n}", + }, + { + name: "map of json.Number decimal", + format: FloatFormatDecimal, + value: map[int]json.Number{1: "0.123", 2: "45.67", 3: "100"}, + wantText: "{\n \"1\": 0.123,\n \"2\": 45.67,\n \"3\": 100\n}", + }, + { + name: "map of json.Number scientific", + format: FloatFormatScientific, + value: map[int]json.Number{1: "0.123", 2: "45.67", 3: "100"}, + wantText: "{\n \"1\": 1.23e-01,\n \"2\": 4.567e+01,\n \"3\": 1e+02\n}", + }, + // map of any + { + name: "map of any auto", + format: FloatFormatAuto, + value: map[string]interface{}{"a": []float32{12.34}, "b": []int{100}}, + wantText: "{\n \"a\": [\n 12.34\n ],\n \"b\": [\n 100\n ]\n}", + }, + { + name: "map of any decimal", + format: FloatFormatDecimal, + value: map[string]interface{}{"a": []float32{12.34}, "b": []int{100}}, + wantText: "{\n \"a\": [\n 12.34\n ],\n \"b\": [\n 100\n ]\n}", + }, + { + name: "map of any scientific", + format: FloatFormatScientific, + value: map[string]interface{}{"a": []float32{12.3}, "b": []int{100}}, + wantText: "{\n \"a\": [\n 1.23e+01\n ],\n \"b\": [\n 1e+02\n ]\n}", + }, + // dump go values + { + name: "go values auto", + format: FloatFormatAuto, + value: map[[1]int][]float64{{1}: {12345.678}}, + wantText: `map[[1]int]interface {}{ + [1]int{ + 1, + }: []interface {}{ + *httpexpect.formatNumber12_345.678, + }, +}`, + }, + { + name: "go values decimal", + format: FloatFormatDecimal, + value: map[[1]int][]float64{{1}: {12345.678}}, + wantText: `map[[1]int]interface {}{ + [1]int{ + 1, + }: []interface {}{ + *httpexpect.formatNumber12_345.678, + }, +}`, + }, + { + name: "go values scientific", + format: FloatFormatScientific, + value: map[[1]int][]float64{{1}: {12345.678}}, + wantText: `map[[1]int]interface {}{ + [1]int{ + 1, + }: []interface {}{ + *httpexpect.formatNumber1.234_567_8e+04, + }, +}`, + }, } for _, tc := range cases {