Repository: gavv/httpexpect (go)

Issue:
Implement Array.IsOrdered
Implement new assertion `Array.IsOrdered`, which succeeds if array elements are sorted. Also add counterpart `NotIsOrdered`.

It should have optional argument `less func(index int, value *Value)`, which defines the function to compare elements (like in sort.Slice):

* If function is provided, `IsOrdered` can compare arbitrary arrays.

* If function is not provided, `IsOrdered` should use builtin comparison for booleans, numbers, strings, durations, and datetimes. It will require that all array elements be of the same type and it is one of these types (after converting to canonical form, see canon.go).

See `String.AsNumber` for en example of optional argument.

Assertions should have documentation comment with example and be covered by unit tests.

Pull request:
feat: implement array.IsOrdered()
closes #158

Patch:
diff --git a/array.go b/array.go
index 625bd54..b5ccb72 100644
--- a/array.go
+++ b/array.go
@@ -1198,6 +1198,262 @@ func (a *Array) NotContainsAny(values ...interface{}) *Array {
 	return a
 }
 
+// IsOrdered succeeds if every element is not less than the previous element
+// as defined on the given `less` comparator function.
+// For default, it will use built-in comparator function for each data type.
+// Built-in comparator requires all elements in the array to have same data type.
+// Array with 0 or 1 element will always succeed
+//
+// Example:
+//
+//	array := NewArray(t, []interface{}{100, 101, 102})
+//	array.IsOrdered() // succeeds
+//	array.IsOrdered(func(x, y *httpexpect.Value) bool {
+//		return x.Number().Raw() < y.Number().Raw()
+//	}) // succeeds
+func (a *Array) IsOrdered(less ...func(x, y *Value) bool) *Array {
+	a.chain.enter("IsOrdered()")
+	defer a.chain.leave()
+
+	if a.chain.failed() {
+		return a
+	}
+
+	if len(less) > 1 {
+		a.chain.fail(AssertionFailure{
+			Type: AssertUsage,
+			Errors: []error{
+				errors.New("unexpected multiple less arguments"),
+			},
+		})
+		return a
+	}
+
+	if len(a.value) <= 1 {
+		return a
+	}
+
+	var lessFn func(x, y *Value) bool
+	if len(less) == 1 {
+		lessFn = less[0]
+	} else {
+		lessFn = a.getBuiltinComparator()
+		if a.chain.failed() {
+			return a
+		}
+	}
+
+	var chainFailure bool
+	for i := 0; i < len(a.value)-1; i++ {
+		xChain := a.chain.clone()
+		xChain.replace("IsOrdered[%d]", i)
+		xChain.setFailCallback(func() {
+			chainFailure = true
+		})
+		x := newValue(xChain, a.value[i])
+
+		yChain := a.chain.clone()
+		yChain.replace("IsOrdered[%d]", i+1)
+		yChain.setFailCallback(func() {
+			chainFailure = true
+		})
+		y := newValue(yChain, a.value[i+1])
+
+		if lessFn(y, x) {
+			a.chain.fail(AssertionFailure{
+				Type:      AssertLt,
+				Actual:    &AssertionValue{x.value},
+				Expected:  &AssertionValue{y.value},
+				Reference: &AssertionValue{a.value},
+				Errors: []error{
+					errors.New("expected: array is ordered"),
+					fmt.Errorf("element %v must be less than element %v, but it isn't",
+						x.value, y.value),
+				},
+			})
+			return a
+		}
+
+		if chainFailure {
+			a.chain.setFailed()
+			return a
+		}
+	}
+
+	return a
+}
+
+// NotOrdered succeeds if at least one element is less than the previous element
+// as defined on the given `less` comparator function.
+// For default, it will use built-in comparator function for each data type.
+// Built-in comparator requires all elements in the array to have same data type.
+// Array with 0 or 1 element will always succeed
+//
+// Example:
+//
+//	array := NewArray(t, []interface{}{102, 101, 100})
+//	array.NotOrdered() // succeeds
+//	array.NotOrdered(func(x, y *httpexpect.Value) bool {
+//		return x.Number().Raw() < y.Number().Raw()
+//	}) // succeeds
+func (a *Array) NotOrdered(less ...func(x, y *Value) bool) *Array {
+	a.chain.enter("NotOrdered()")
+	defer a.chain.leave()
+
+	if a.chain.failed() {
+		return a
+	}
+
+	if len(less) > 1 {
+		a.chain.fail(AssertionFailure{
+			Type: AssertUsage,
+			Errors: []error{
+				errors.New("unexpected multiple less arguments"),
+			},
+		})
+		return a
+	}
+
+	if len(a.value) <= 1 {
+		return a
+	}
+
+	var lessFn func(x, y *Value) bool
+	if len(less) == 1 {
+		lessFn = less[0]
+	} else {
+		lessFn = a.getBuiltinComparator()
+		if a.chain.failed() {
+			return a
+		}
+	}
+
+	var i int
+	var chainFailure bool
+	ordered := true
+	for i = 0; i < len(a.value)-1; i++ {
+		xChain := a.chain.clone()
+		xChain.replace("IsOrdered[%d]", i)
+		xChain.setFailCallback(func() {
+			chainFailure = true
+		})
+		x := newValue(xChain, a.value[i])
+
+		yChain := a.chain.clone()
+		yChain.replace("IsOrdered[%d]", i+1)
+		yChain.setFailCallback(func() {
+			chainFailure = true
+		})
+		y := newValue(yChain, a.value[i+1])
+
+		if lessFn(y, x) {
+			ordered = false
+			break
+		}
+
+		if chainFailure {
+			a.chain.setFailed()
+			return a
+		}
+	}
+
+	if ordered {
+		a.chain.fail(AssertionFailure{
+			Type:   AssertValid,
+			Actual: &AssertionValue{a.value},
+			Errors: []error{
+				errors.New("expected: array is not ordered"),
+			},
+		})
+	}
+
+	return a
+}
+
+func (a *Array) getBuiltinComparator() func(x, y *Value) bool {
+	a.validateElementsType()
+	if a.chain.failed() {
+		return nil
+	}
+
+	return a.constructLessFn()
+}
+
+func (a *Array) validateElementsType() {
+	var prev interface{}
+	for idx, curr := range a.value {
+		switch curr.(type) {
+		case bool, float64, string, nil:
+			// ok, do nothing
+		default:
+			a.chain.fail(AssertionFailure{
+				Type:   AssertBelongs,
+				Actual: &AssertionValue{fmt.Sprintf("%T", curr)},
+				Expected: &AssertionValue{AssertionList{
+					"Boolean (bool)",
+					"Number (int*, uint*, float32, float64)",
+					"String (string)",
+					"Null (nil)",
+				}},
+				Reference: &AssertionValue{curr},
+				Errors: []error{
+					errors.New("expected: type of each element of reference array" +
+						" belongs to given list"),
+					fmt.Errorf("element %v has type %T", curr, curr),
+				},
+			})
+			return
+		}
+		if idx > 0 && fmt.Sprintf("%T", curr) != fmt.Sprintf("%T", prev) {
+			a.chain.fail(AssertionFailure{
+				Type:      AssertEqual,
+				Actual:    &AssertionValue{fmt.Sprintf("%T", curr)},
+				Expected:  &AssertionValue{fmt.Sprintf("%T", prev)},
+				Reference: &AssertionValue{curr},
+				Errors: []error{
+					errors.New("expected: types of all elements of reference array are the same"),
+					fmt.Errorf("element %v has type %T while element %v has type %T",
+						prev, prev, curr, curr),
+				},
+			})
+			return
+		}
+		prev = curr
+	}
+}
+
+func (a *Array) constructLessFn() func(x, y *Value) bool {
+	if len(a.value) > 0 {
+		switch a.value[0].(type) {
+		case bool:
+			return func(x, y *Value) bool {
+				xVal := x.Raw().(bool)
+				yVal := y.Raw().(bool)
+				return (!xVal && yVal)
+			}
+		case float64:
+			return func(x, y *Value) bool {
+				xVal := x.Raw().(float64)
+				yVal := y.Raw().(float64)
+				return xVal < yVal
+			}
+		case string:
+			return func(x, y *Value) bool {
+				xVal := x.Raw().(string)
+				yVal := y.Raw().(string)
+				return xVal < yVal
+			}
+		case nil:
+			return func(x, y *Value) bool {
+				// `nil` is never less than `nil`
+				return false
+			}
+		}
+	}
+
+	return nil
+}
+
 func countElement(array []interface{}, element interface{}) int {
 	count := 0
 	for _, e := range array {


Test patch:
diff --git a/_examples/fruits_test.go b/_examples/fruits_test.go
index 6e09f1e..68c2d6f 100644
--- a/_examples/fruits_test.go
+++ b/_examples/fruits_test.go
@@ -31,15 +31,33 @@ func TestFruits(t *testing.T) {
 	apple := map[string]interface{}{
 		"colors": []interface{}{"green", "red"},
 		"weight": 200,
+		"image": []map[string]string{
+			{
+				"id":   " 1",
+				"url":  "http://example.com",
+				"type": "fruit",
+			},
+			{
+				"id":   "2",
+				"url":  "http://example2.com",
+				"type": "fruit",
+			},
+		},
 	}
 
 	e.PUT("/fruits/apple").WithJSON(apple).
 		Expect().
 		Status(http.StatusNoContent).NoContent()
 
-	e.GET("/fruits").
+	fruits := e.GET("/fruits").
 		Expect().
-		Status(http.StatusOK).JSON().Array().ContainsOnly("orange", "apple")
+		Status(http.StatusOK).JSON().Array()
+
+	fruits.Every(func(index int, value *httpexpect.Value) {
+		value.String().NotEmpty()
+	})
+	fruits.ContainsAny("orange", "melon")
+	fruits.ContainsOnly("orange", "apple")
 
 	e.GET("/fruits/orange").
 		Expect().
@@ -54,14 +72,25 @@ func TestFruits(t *testing.T) {
 		Expect().
 		Status(http.StatusOK).JSON().Object()
 
-	obj.Keys().ContainsOnly("colors", "weight")
+	obj.Keys().ContainsOnly("colors", "weight", "image")
 
 	obj.Value("colors").Array().Elements("green", "red")
 	obj.Value("colors").Array().Element(0).String().Equal("green")
 	obj.Value("colors").Array().Element(1).String().Equal("red")
+	obj.Value("colors").Array().Element(1).String().IsASCII()
+	obj.Value("colors").Array().Element(1).String().HasPrefix("re")
+	obj.Value("colors").Array().Element(1).String().HasSuffix("ed")
 
 	obj.Value("weight").Number().Equal(200)
 
+	for _, element := range obj.Value("image").Array().Iter() {
+		element.Object().ContainsKey("id")
+		element.Object().ContainsValue("fruit")
+		element.Object().ContainsSubset(map[string]interface{}{
+			"type": "fruit",
+		})
+	}
+
 	e.GET("/fruits/melon").
 		Expect().
 		Status(http.StatusNotFound)
diff --git a/array_test.go b/array_test.go
index b7bdece..bed5b1f 100644
--- a/array_test.go
+++ b/array_test.go
@@ -1290,3 +1290,286 @@ func TestArray_NotFind(t *testing.T) {
 		array.chain.assertFailed(t)
 	})
 }
+func TestArray_IsOrdered(t *testing.T) {
+	type args struct {
+		values      []interface{}
+		less        []func(x, y *Value) bool
+		chainFailed bool
+	}
+	tests := []struct {
+		name   string
+		args   args
+		wantOK bool
+	}{
+		{
+			name: "array boolean ordered",
+			args: args{
+				values: []interface{}{false, false, true, true},
+			},
+			wantOK: true,
+		},
+		{
+			name: "array number ordered",
+			args: args{
+				values: []interface{}{1, 1, 2, 3},
+			},
+			wantOK: true,
+		},
+		{
+			name: "array string ordered",
+			args: args{
+				values: []interface{}{"", "a", "b", "ba"},
+			},
+			wantOK: true,
+		},
+		{
+			name: "array of nil elements",
+			args: args{
+				values: []interface{}{nil, nil, nil},
+			},
+			wantOK: true,
+		},
+		{
+			name: "wrong order",
+			args: args{
+				values: []interface{}{3, 2, 1},
+			},
+			wantOK: false,
+		},
+		{
+			name: "user-defined less function",
+			args: args{
+				values: []interface{}{1, 2, 3},
+				less: []func(x, y *Value) bool{
+					func(x, y *Value) bool {
+						valX := x.Number().Raw()
+						valY := y.Number().Raw()
+						return valX < valY
+					},
+				},
+			},
+			wantOK: true,
+		},
+		{
+			name: "invalid - failed type assertion on less function",
+			args: args{
+				values: []interface{}{1, 2, 3},
+				less: []func(x, y *Value) bool{
+					func(x, y *Value) bool {
+						x.String()
+						y.String()
+						return false
+					},
+				},
+			},
+			wantOK: false,
+		},
+		{
+			name: "invalid - multiple less functions",
+			args: args{
+				values: []interface{}{1, 2, 3},
+				less: []func(x, y *Value) bool{
+					func(x, y *Value) bool {
+						return false
+					},
+					func(x, y *Value) bool {
+						return true
+					},
+				},
+			},
+			wantOK: false,
+		},
+		{
+			name: "invalid - data type not allowed",
+			args: args{
+				values: []interface{}{[]int{1, 2}, []int{3, 4}, []int{5, 6}},
+				less:   []func(x, y *Value) bool{},
+			},
+			wantOK: false,
+		},
+		{
+			name: "invalid - multiple data types found",
+			args: args{
+				values: []interface{}{1, "abc", true},
+				less:   []func(x, y *Value) bool{},
+			},
+			wantOK: false,
+		},
+		{
+			name: "empty array",
+			args: args{
+				values: []interface{}{},
+			},
+			wantOK: true,
+		},
+		{
+			name: "one element",
+			args: args{
+				values: []interface{}{1},
+			},
+			wantOK: true,
+		},
+		{
+			name: "chain has failed before",
+			args: args{
+				chainFailed: true,
+			},
+			wantOK: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reporter := newMockReporter(t)
+			a := NewArray(reporter, tt.args.values)
+			a.IsOrdered(tt.args.less...)
+			if tt.wantOK {
+				a.chain.assertNotFailed(t)
+			} else {
+				a.chain.assertFailed(t)
+			}
+			a.chain.clearFailed()
+		})
+	}
+}
+
+func TestArray_NotOrdered(t *testing.T) {
+	type args struct {
+		values      []interface{}
+		less        []func(x, y *Value) bool
+		chainFailed bool
+	}
+	tests := []struct {
+		name   string
+		args   args
+		wantOK bool
+	}{
+		{
+			name: "array boolean not ordered",
+			args: args{
+				values: []interface{}{true, true, false, false},
+			},
+			wantOK: true,
+		},
+		{
+			name: "array number not ordered",
+			args: args{
+				values: []interface{}{3, 1, 1, 2},
+			},
+			wantOK: true,
+		},
+		{
+			name: "array string not ordered",
+			args: args{
+				values: []interface{}{"z", "y", "x", ""},
+			},
+			wantOK: true,
+		},
+		{
+			name: "array of nil elements",
+			args: args{
+				values: []interface{}{nil, nil, nil},
+			},
+			wantOK: false,
+		},
+		{
+			name: "array ordered",
+			args: args{
+				values: []interface{}{1, 2, 3},
+			},
+			wantOK: false,
+		},
+		{
+			name: "user-defined less function",
+			args: args{
+				values: []interface{}{1, 2, 3},
+				less: []func(x, y *Value) bool{
+					func(x, y *Value) bool {
+						valX := x.Number().Raw()
+						valY := y.Number().Raw()
+						return valX >= valY
+					},
+				},
+			},
+			wantOK: true,
+		},
+		{
+			name: "invalid - failed type assertion on less function",
+			args: args{
+				values: []interface{}{1, 2},
+				less: []func(x, y *Value) bool{
+					func(x, y *Value) bool {
+						x.String()
+						y.String()
+						return false
+					},
+				},
+			},
+			wantOK: false,
+		},
+		{
+			name: "invalid - multiple less functions",
+			args: args{
+				values: []interface{}{1, 2, 3},
+				less: []func(x, y *Value) bool{
+					func(x, y *Value) bool {
+						return false
+					},
+					func(x, y *Value) bool {
+						return true
+					},
+				},
+			},
+			wantOK: false,
+		},
+		{
+			name: "invalid - data type not allowed",
+			args: args{
+				values: []interface{}{[]int{1, 2}, []int{3, 4}, []int{5, 6}},
+				less:   []func(x, y *Value) bool{},
+			},
+			wantOK: false,
+		},
+		{
+			name: "invalid - multiple data types found",
+			args: args{
+				values: []interface{}{1, "abc", true},
+				less:   []func(x, y *Value) bool{},
+			},
+			wantOK: false,
+		},
+		{
+			name: "empty array",
+			args: args{
+				values: []interface{}{},
+			},
+			wantOK: true,
+		},
+		{
+			name: "one element",
+			args: args{
+				values: []interface{}{1},
+			},
+			wantOK: true,
+		},
+		{
+			name: "chain has failed before",
+			args: args{
+				chainFailed: true,
+			},
+			wantOK: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reporter := newMockReporter(t)
+			a := NewArray(reporter, tt.args.values)
+			a.NotOrdered(tt.args.less...)
+			if tt.wantOK {
+				a.chain.assertNotFailed(t)
+			} else {
+				a.chain.assertFailed(t)
+			}
+			a.chain.clearFailed()
+		})
+	}
+}