diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2026-06-20 20:27:48 +0200 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2026-06-20 20:27:48 +0200 |
| commit | 6e285f65c5a5ed8e27857a3688a7c86b37688b13 (patch) | |
| tree | 3050315491ff0c53ec6eab0affaf5fbea5a8ef97 | |
| parent | c0ca4d891f08a5baef22bee16a1a4feb0089bbae (diff) | |
| download | go-gg-6e285f65c5a5ed8e27857a3688a7c86b37688b13.tar.gz | |
assert: improve output of Equal() for slice types with only partial diffs
| -rw-r--r-- | assert/equal.go | 71 | ||||
| -rw-r--r-- | assert/equal_test.go | 70 | ||||
| -rw-r--r-- | internal/path/path.go | 15 |
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 { |
