aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-06-20 20:27:48 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-06-20 20:27:48 +0200
commit6e285f65c5a5ed8e27857a3688a7c86b37688b13 (patch)
tree3050315491ff0c53ec6eab0affaf5fbea5a8ef97
parentc0ca4d891f08a5baef22bee16a1a4feb0089bbae (diff)
downloadgo-gg-6e285f65c5a5ed8e27857a3688a7c86b37688b13.tar.gz
assert: improve output of Equal() for slice types with only partial diffs
-rw-r--r--assert/equal.go71
-rw-r--r--assert/equal_test.go70
-rw-r--r--internal/path/path.go15
3 files changed, 148 insertions, 8 deletions
diff --git a/assert/equal.go b/assert/equal.go
index 122520b..2cafb44 100644
--- a/assert/equal.go
+++ b/assert/equal.go
@@ -7,6 +7,8 @@ import (
"fmt"
"reflect"
"slices"
+ "strings"
+ "unicode/utf8"
"go.xyrillian.de/gg/internal/path"
)
@@ -108,6 +110,20 @@ func findInequalities(p path.Path, actual, expected reflect.Value) (result []ine
}
func findInequalitiesInArrayOrSlice(p path.Path, actual, expected reflect.Value) (result []inequality) {
+ // special case: for ~[]byte types (e.g. json.RawMessage) containing a valid Unicode string on both sides,
+ // a diff using string literals is likely to be vastly more readable
+ if actual.Type().Elem() == reflect.TypeFor[byte]() {
+ actualPayload := actual.Convert(reflect.TypeFor[[]byte]()).Interface().([]byte)
+ expectedPayload := expected.Convert(reflect.TypeFor[[]byte]()).Interface().([]byte)
+ if utf8.Valid(actualPayload) && utf8.Valid(expectedPayload) {
+ return []inequality{{
+ Pointer: p.AsGoExpression("actual"),
+ Actual: formatByteSliceViaString(actualPayload),
+ Expected: formatByteSliceViaString(expectedPayload),
+ }}
+ }
+ }
+
// recurse into all elements
for idx := range max(actual.Len(), expected.Len()) {
subpath := append(p, path.IndexElement(idx))
@@ -141,11 +157,7 @@ func findInequalitiesInArrayOrSlice(p path.Path, actual, expected reflect.Value)
for _, ineq := range result {
overallTextLength += len(ineq.Pointer) + len(ineq.Actual) + len(ineq.Expected)
}
- ineq := inequality{
- Pointer: p.AsGoExpression("actual"),
- Actual: formatValue(actual),
- Expected: formatValue(expected),
- }
+ ineq := buildSingleInequalityForArrayOrSlice(p, actual, expected)
if len(ineq.Pointer)+len(ineq.Actual)+len(ineq.Expected) < overallTextLength {
return []inequality{ineq}
}
@@ -154,6 +166,55 @@ func findInequalitiesInArrayOrSlice(p path.Path, actual, expected reflect.Value)
return result
}
+func formatByteSliceViaString(buf []byte) string {
+ str := string(buf)
+ if strings.Contains(str, `"`) && !strings.Contains(str, "`") {
+ return fmt.Sprintf("[]byte(`%s`)", str)
+ } else {
+ return fmt.Sprintf("[]byte(%q)", str)
+ }
+}
+
+func buildSingleInequalityForArrayOrSlice(p path.Path, actual, expected reflect.Value) inequality {
+ // This is a helper for findInequalitiesInArrayOrSlice() that reports only a single inequality for the entire thing.
+ // But it still tries to be clever, and will omit the longest common prefix and suffix to shorten the output.
+ maxTruncateableLength := min(actual.Len(), expected.Len())
+
+ commonPrefixLength := 0
+ for idx := range maxTruncateableLength {
+ actualElem := actual.Index(idx)
+ expectedElem := expected.Index(idx)
+ if reflect.DeepEqual(actualElem.Interface(), expectedElem.Interface()) {
+ commonPrefixLength = idx + 1
+ } else {
+ break
+ }
+ }
+
+ commonSuffixLength := 0
+ for idx := range max(0, maxTruncateableLength-commonPrefixLength) {
+ actualElem := actual.Index(actual.Len() - 1 - idx)
+ expectedElem := expected.Index(expected.Len() - 1 - idx)
+ if reflect.DeepEqual(actualElem.Interface(), expectedElem.Interface()) {
+ commonSuffixLength = idx + 1
+ } else {
+ break
+ }
+ }
+
+ if commonPrefixLength > 0 || commonSuffixLength > 0 {
+ p = append(p, path.SliceElement(commonPrefixLength, expected.Len()-commonSuffixLength))
+ actual = actual.Slice(commonPrefixLength, actual.Len()-commonSuffixLength)
+ expected = expected.Slice(commonPrefixLength, expected.Len()-commonSuffixLength)
+ }
+
+ return inequality{
+ Pointer: p.AsGoExpression("actual"),
+ Actual: formatValue(actual),
+ Expected: formatValue(expected),
+ }
+}
+
func findInequalitiesInMap(p path.Path, actual, expected reflect.Value) (result []inequality) {
// recurse into all keys of `actual`
iter := actual.MapRange()
diff --git a/assert/equal_test.go b/assert/equal_test.go
index f325916..df6fd70 100644
--- a/assert/equal_test.go
+++ b/assert/equal_test.go
@@ -4,6 +4,7 @@
package assert_test
import (
+ "encoding/json"
"strings"
"testing"
@@ -38,6 +39,16 @@ func TestEqual(t *testing.T) {
assert.Equal(t, wrongSlice, correctSlice)
}, `at actual[1]: expected 2, but got 42`)
+ // basic test for slices of different lengths
+ overlongSlice := []int{1, 2, 3, 4}
+ truncatedSlice := []int{1, 2}
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.Equal(t, overlongSlice, correctSlice)
+ }, `at actual[3]: expected <missing>, but got 4`)
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.Equal(t, truncatedSlice, correctSlice)
+ }, `at actual[2]: expected 3, but got <missing>`)
+
// slices: report the entire slice if it comes out more compact than reporting each different element
expectErrors(t, func(t assert.TestingTB) {
var (
@@ -65,6 +76,62 @@ func TestEqual(t *testing.T) {
at actual[5]["bar"]: expected 7, but got 8
`)
+ // slices: omit the longest common prefix/suffix when reporting the entire slice as different
+ expectErrors(t, func(t assert.TestingTB) {
+ var (
+ expected = make([]int, 100)
+ actual = make([]int, 100)
+ )
+ for idx := range expected {
+ expected[idx] = idx
+ if idx >= 30 && idx <= 40 {
+ actual[idx] = 70 - idx // This flips the run [30:40] around.
+ } else {
+ actual[idx] = idx
+ }
+ }
+ assert.Equal(t, actual, expected)
+ }, `at actual[30:41]: expected []int{30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40}, but got []int{40, 39, 38, 37, 36, 35, 34, 33, 32, 31, 30}`)
+
+ // slices: as a corner case, omission of the longest common prefix/suffix may truncate one side to an empty slice
+ expectErrors(t, func(t assert.TestingTB) {
+ var (
+ expected = make([]int, 100)
+ actual = make([]int, 102)
+ )
+ for idx := range expected {
+ expected[idx] = idx
+ }
+ for idx := range actual {
+ if idx < 34 {
+ actual[idx] = idx
+ } else {
+ actual[idx] = idx - 2
+ }
+ // The end result of this is [0, 1, ...,, 32, 33, 32, 33, 34, ..., 99].
+ }
+ assert.Equal(t, actual, expected)
+ }, `at actual[34:34]: expected []int{}, but got []int{32, 33}`)
+
+ // slices: special handling for []byte that renders them like strings if they are all ASCII
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.Equal(t, []byte("hallo"), []byte("hello"))
+ }, `expected []byte("hello"), but got []byte("hallo")`)
+ correctPayload, err := json.Marshal(map[string]int{"foo": 1, "bar": 2})
+ if err != nil {
+ t.Fatal(err)
+ }
+ wrongPayload, err := json.Marshal(map[string]int{"foo": 1, "bar": 3})
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectErrors(t, func(t assert.TestingTB) {
+ assert.Equal(t, json.RawMessage(wrongPayload), json.RawMessage(correctPayload))
+ // ^ Without the special handling, this would produce the nonsensical output:
+ // at actual[7]: expected 0x32, but got 0x33
+ }, "expected []byte(`{\"bar\":2,\"foo\":1}`), but got []byte(`{\"bar\":3,\"foo\":1}`)")
+ // ^ This checks that backticks are used when it makes a nicer output.
+
// basic test for maps
correctMap := map[string]int{
"one": 1,
@@ -161,6 +228,3 @@ func TestEqual(t *testing.T) {
at actual[0].Name: expected "Alice", but got "Bob"
`)
}
-
-// TODO: check coverage
-// TODO: implement special case for ~[]byte containing valid UTF-8
diff --git a/internal/path/path.go b/internal/path/path.go
index 333b3b2..fa3363e 100644
--- a/internal/path/path.go
+++ b/internal/path/path.go
@@ -42,17 +42,27 @@ type Element struct {
Index int
// only used by package assert (panics when used with AsJSONPointer())
+ Slice Option[SliceBounds]
MapKey Option[any] // holds a value of type K for traversing into types ~map[K]V
TypeCast string // holds a %T formatting of a type
Dereference bool // marks when a pointer is dereferenced
}
+// SliceBounds appears in type [Element].
+type SliceBounds struct {
+ Start int
+ End int
+}
+
// KeyElement is a shorthand for constructing an Element with the Key field set.
func KeyElement(key string) Element { return Element{Key: Some(key)} }
// IndexElement is a shorthand for constructing an Element with the Index field set.
func IndexElement(idx int) Element { return Element{Index: idx} }
+// SliceElement is a shorthand for constructing an Element with the Slice field set.
+func SliceElement(start, end int) Element { return Element{Slice: Some(SliceBounds{start, end})} }
+
// MapKeyElement is a shorthand for constructing an Element with the MapKey field set.
func MapKeyElement(key any) Element { return Element{MapKey: Some(key)} }
@@ -79,6 +89,9 @@ func (p Path) AsJSONPointer() string {
if elem.MapKey.IsSome() {
panic("MapKey elements cannot be used with AsJSONPointer()")
}
+ if elem.Slice.IsSome() {
+ panic("Slice elements cannot be used with AsJSONPointer()")
+ }
if key, ok := elem.Key.Unpack(); ok {
fragments[idx+1] = keyIntoPointerFragment(key)
} else {
@@ -115,6 +128,8 @@ func (p Path) AsGoExpression(baseVariable string) string {
fmt.Fprintf(b, `.(%s)`, elem.TypeCast)
} else if mapKey, ok := elem.MapKey.Unpack(); ok {
fmt.Fprintf(b, `[%#v]`, mapKey)
+ } else if bounds, ok := elem.Slice.Unpack(); ok {
+ fmt.Fprintf(b, `[%d:%d]`, bounds.Start, bounds.End)
} else if key, ok := elem.Key.Unpack(); ok {
fmt.Fprintf(b, `.%s`, key)
} else {