diff --git a/.github/workflows/go.yml b/.github/workflows/go-test-matrix.yml similarity index 78% rename from .github/workflows/go.yml rename to .github/workflows/go-test-matrix.yml index 5177bca..3ed7800 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go-test-matrix.yml @@ -1,13 +1,10 @@ -# This workflow will build a golang project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go - -name: Go +name: "Go Test Matrix" on: push: - branches: [ "master" ] + branches: [ "main" ] pull_request: - branches: [ "master" ] + branches: [ "main" ] jobs: test: diff --git a/.gitignore b/.gitignore index 53f12f0..5f18ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.swp *.out +coverage.* diff --git a/deep.go b/deep.go index 0714249..439ecd1 100644 --- a/deep.go +++ b/deep.go @@ -8,6 +8,7 @@ import ( "fmt" "log" "reflect" + "slices" "strings" ) @@ -76,10 +77,10 @@ type cmp struct { diff []string buff []string floatFormat string - flag map[byte]bool + conf Differ } -var errorType = reflect.TypeOf((*error)(nil)).Elem() +var errorType = reflect.TypeFor[error]() // https://blog.carlana.net/post/2024/golang-reflect-type-for/ // Equal compares variables a and b, recursing into their structure up to // MaxDepth levels deep (if greater than zero), and returns a list of differences, @@ -91,39 +92,32 @@ var errorType = reflect.TypeOf((*error)(nil)).Elem() // // When comparing a struct, if a field has the tag `deep:"-"` then it will be // ignored. -func Equal(a, b interface{}, flags ...interface{}) []string { - aVal := reflect.ValueOf(a) - bVal := reflect.ValueOf(b) - c := &cmp{ - diff: []string{}, - buff: []string{}, - floatFormat: fmt.Sprintf("%%.%df", FloatPrecision), - flag: map[byte]bool{}, - } - for i := range flags { - c.flag[flags[i].(byte)] = true - } - if a == nil && b == nil { - return nil - } else if a == nil && b != nil { - c.saveDiff("", b) - } else if a != nil && b == nil { - c.saveDiff(a, "") - } - if len(c.diff) > 0 { - return c.diff - } +func Equal(a, b any, flags ...any) []string { + // error ignored to preserve API + return NewDiffer( + WithCompareFunctions(CompareFunctions), + WithCompareUnexportedFields(CompareUnexportedFields), + WithFloatPrecision(uint(FloatPrecision)), + WithIgnoreSliceOrder(hasFlag(flags, FLAG_IGNORE_SLICE_ORDER)), + WithLogErrors(LogErrors), + WithMaxDepth(uint(MaxDepth)), + WithMaxDiff(uint(MaxDiff)), + WithNilMapsAreEmpty(NilMapsAreEmpty), + WithNilPointersAreZero(NilPointersAreZero), + WithNilSlicesAreEmpty(NilSlicesAreEmpty), + ).Diff(a, b) +} - c.equals(aVal, bVal, 0) - if len(c.diff) > 0 { - return c.diff // diffs - } - return nil // no diffs +func hasFlag(flags []any, flag byte) bool { + return slices.ContainsFunc(flags, func(i any) bool { + v, ok := i.(byte) + return ok && v == flag + }) } func (c *cmp) equals(a, b reflect.Value, level int) { - if MaxDepth > 0 && level > MaxDepth { - logError(ErrMaxRecursion) + if c.conf.maxDepth > 0 && uint(level) > c.conf.maxDepth { + c.logError(ErrMaxRecursion) return } @@ -153,7 +147,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { bFullType := bType.PkgPath() + "." + bType.Name() c.saveDiff(aFullType, bFullType) } - logError(ErrTypeMismatch) + c.logError(ErrTypeMismatch) return } @@ -162,8 +156,8 @@ func (c *cmp) equals(a, b reflect.Value, level int) { bKind := b.Kind() // Do a and b have underlying elements? Yes if they're ptr or interface. - aElem := aKind == reflect.Ptr || aKind == reflect.Interface - bElem := bKind == reflect.Ptr || bKind == reflect.Interface + aElem := aKind == reflect.Pointer || aKind == reflect.Interface + bElem := bKind == reflect.Pointer || bKind == reflect.Interface // If both types implement the error interface, compare the error strings. // This must be done before dereferencing because errors.New() returns a @@ -193,10 +187,10 @@ func (c *cmp) equals(a, b reflect.Value, level int) { if bElem { b = b.Elem() } - if aElem && NilPointersAreZero && !a.IsValid() && b.IsValid() { + if aElem && c.conf.nilPointersAreZero && !a.IsValid() && b.IsValid() { a = reflect.Zero(b.Type()) } - if bElem && NilPointersAreZero && !b.IsValid() && a.IsValid() { + if bElem && c.conf.nilPointersAreZero && !b.IsValid() && a.IsValid() { b = reflect.Zero(a.Type()) } c.equals(a, b, level+1) @@ -244,7 +238,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { */ for i := 0; i < a.NumField(); i++ { - if aType.Field(i).PkgPath != "" && !CompareUnexportedFields { + if aType.Field(i).PkgPath != "" && !c.conf.compareUnexportedFields { continue // skip unexported field, e.g. s in type T struct {s string} } @@ -264,7 +258,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.pop() // pop field name from buff - if len(c.diff) >= MaxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { break } } @@ -285,7 +279,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { */ if a.IsNil() || b.IsNil() { - if NilMapsAreEmpty { + if c.conf.nilMapsAreEmpty { if a.IsNil() && b.Len() != 0 { c.saveDiff("", b) return @@ -320,7 +314,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.pop() - if len(c.diff) >= MaxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { return } } @@ -333,22 +327,22 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.push(fmt.Sprintf("map[%v]", key)) c.saveDiff("", b.MapIndex(key)) c.pop() - if len(c.diff) >= MaxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { return } } case reflect.Array: n := a.Len() - for i := 0; i < n; i++ { + for i := range n { c.push(fmt.Sprintf("array[%d]", i)) c.equals(a.Index(i), b.Index(i), level+1) c.pop() - if len(c.diff) >= MaxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { break } } case reflect.Slice: - if NilSlicesAreEmpty { + if c.conf.nilSlicesAreEmpty { if a.IsNil() && b.Len() != 0 { c.saveDiff("", b) return @@ -378,30 +372,26 @@ func (c *cmp) equals(a, b reflect.Value, level int) { return } - if c.flag[FLAG_IGNORE_SLICE_ORDER] { + if c.conf.ignoreSliceOrder { // Compare slices by value and value count; ignore order. // Value equality is implicitly established by the maps: // any value v1 will hash to the same map value if it's equal // to another value v2. Then equality is determined by value // count: presuming v1==v2, then the slices are equal if there // are equal numbers of v1 in each slice. - am := map[interface{}]int{} + am := map[any]int{} for i := 0; i < a.Len(); i++ { am[a.Index(i).Interface()] += 1 } - bm := map[interface{}]int{} + bm := map[any]int{} for i := 0; i < b.Len(); i++ { bm[b.Index(i).Interface()] += 1 } - c.cmpMapValueCounts(a, b, am, bm, true) // a cmp b - c.cmpMapValueCounts(b, a, bm, am, false) // b cmp a + c.cmpMapValueCounts(am, bm, true) // a cmp b + c.cmpMapValueCounts(bm, am, false) // b cmp a } else { // Compare slices by order - n := aLen - if bLen > aLen { - n = bLen - } - for i := 0; i < n; i++ { + for i := range max(aLen, bLen) { c.push(fmt.Sprintf("slice[%d]", i)) if i < aLen && i < bLen { c.equals(a.Index(i), b.Index(i), level+1) @@ -411,7 +401,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.saveDiff("", b.Index(i)) } c.pop() - if len(c.diff) >= MaxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { break } } @@ -451,7 +441,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.saveDiff(a.String(), b.String()) } case reflect.Func: - if CompareFunctions { + if c.conf.compareFunctions { if !a.IsNil() || !b.IsNil() { aVal, bVal := "nil func", "nil func" if !a.IsNil() { @@ -464,7 +454,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { } } default: - logError(ErrNotHandled) + c.logError(ErrNotHandled) } } @@ -478,7 +468,7 @@ func (c *cmp) pop() { } } -func (c *cmp) saveDiff(aval, bval interface{}) { +func (c *cmp) saveDiff(aval, bval any) { if len(c.buff) > 0 { varName := strings.Join(c.buff, ".") c.diff = append(c.diff, fmt.Sprintf("%s: %v != %v", varName, aval, bval)) @@ -487,7 +477,7 @@ func (c *cmp) saveDiff(aval, bval interface{}) { } } -func (c *cmp) cmpMapValueCounts(a, b reflect.Value, am, bm map[interface{}]int, a2b bool) { +func (c *cmp) cmpMapValueCounts(am, bm map[any]int, a2b bool) { for v := range am { aCount, _ := am[v] bCount, _ := bm[v] @@ -506,8 +496,8 @@ func (c *cmp) cmpMapValueCounts(a, b reflect.Value, am, bm map[interface{}]int, } } -func logError(err error) { - if LogErrors { +func (c *cmp) logError(err error) { + if c.conf.logErrors { log.Println(err) } } diff --git a/deep_test.go b/deep_test.go index a9238be..4c46998 100644 --- a/deep_test.go +++ b/deep_test.go @@ -1186,12 +1186,12 @@ func TestTimeUnexported(t *testing.T) { } func TestInterface(t *testing.T) { - a := map[string]interface{}{ + a := map[string]any{ "foo": map[string]string{ "bar": "a", }, } - b := map[string]interface{}{ + b := map[string]any{ "foo": map[string]string{ "bar": "b", }, @@ -1212,10 +1212,10 @@ func TestInterface2(t *testing.T) { } }() - a := map[string]interface{}{ + a := map[string]any{ "bar": 1, } - b := map[string]interface{}{ + b := map[string]any{ "bar": 1.23, } diff := deep.Equal(a, b) @@ -1229,10 +1229,10 @@ func TestInterface2(t *testing.T) { func TestInterface3(t *testing.T) { type Value struct{ int } - a := map[string]interface{}{ + a := map[string]any{ "foo": &Value{}, } - b := map[string]interface{}{ + b := map[string]any{ "foo": 1.23, } diff := deep.Equal(a, b) @@ -1432,7 +1432,7 @@ func TestNil(t *testing.T) { } mark := student{"mark", 10} - var someNilThing interface{} = nil + var someNilThing any = nil diff := deep.Equal(someNilThing, mark) if diff == nil { t.Error("Nil value to comparison should not be equal") diff --git a/differ.go b/differ.go new file mode 100644 index 0000000..793b6fe --- /dev/null +++ b/differ.go @@ -0,0 +1,223 @@ +// Package deep provides function deep.Equal which is like reflect.DeepEqual but +// returns a list of differences. This is helpful when comparing complex types +// like structures and maps. +package deep + +import ( + "fmt" + "reflect" +) + +type Opt func(*Differ) + +// WithFloatPrecision is the number of decimal places to round float values +// to when comparing. +func WithFloatPrecision(p uint) Opt { + return func(d *Differ) { + d.floatPrecision = p + } +} + +// WithMaxDiff specifies the maximum number of differences to return. +func WithMaxDiff(m uint) Opt { + return func(d *Differ) { + d.maxDiff = m + } +} + +// WithMaxDepth specifies the maximum levels of a struct to recurse into, +// if greater than zero. If zero, there is no limit. +func WithMaxDepth(m uint) Opt { + return func(d *Differ) { + d.maxDepth = m + } +} + +// WithLogErrors causes errors to be logged to STDERR when true. +func WithLogErrors(b bool) Opt { + return func(d *Differ) { + d.logErrors = b + } +} + +// WithCompareUnexportedFields causes unexported struct fields, like s in +// T{s int}, to be compared when true. This does not work for comparing +// error or Time types on unexported fields because methods on unexported +// fields cannot be called. +func WithCompareUnexportedFields(b bool) Opt { + return func(d *Differ) { + d.compareUnexportedFields = b + } +} + +// WithCompareFunctions compares functions the same as reflect.DeepEqual: +// only two nil functions are equal. Every other combination is not equal. +// This is disabled by default because previous versions of this package +// ignored functions. Enabling it can possibly report new diffs. +func WithCompareFunctions(b bool) Opt { + return func(d *Differ) { + d.compareFunctions = b + } +} + +// WithNilSlicesAreEmpty causes a nil slice to be equal to an empty slice. +func WithNilSlicesAreEmpty(b bool) Opt { + return func(d *Differ) { + d.nilSlicesAreEmpty = b + } +} + +// WithNilMapsAreEmpty causes a nil map to be equal to an empty map. +func WithNilMapsAreEmpty(b bool) Opt { + return func(d *Differ) { + d.nilMapsAreEmpty = b + } +} + +// WithNilPointersAreZero causes a nil pointer to be equal to a zero value. +func WithNilPointersAreZero(b bool) Opt { + return func(d *Differ) { + d.nilPointersAreZero = b + } +} + +// WithIgnoreSliceOrder causes Equal to ignore slice order so that +// []int{1, 2} and []int{2, 1} are equal. Only slices of primitive scalars +// like numbers and strings are supported. Slices of complex types, +// like []T where T is a struct, are undefined because Equal does not +// recurse into the slice value when this flag is enabled. +func WithIgnoreSliceOrder(b bool) Opt { + return func(d *Differ) { + d.ignoreSliceOrder = b + } +} + +// WithInlineTest causes Differ.Diff to call T.Error when diffs are found. +// It also calls T.Helper to make the error report where it's called in the +// unit test, not within Differ.Diff. This option replaces a common pattern: +// +// if diff := deep.Equal("foo", "bar"); diff != nil { +// t.Error(diff) +// } +// +// Instead, where d is a Differ with inline testing: +// +// d.Diff("foo", "bar") +// +// With inline testing, Differ.Diff still returns a Diff (nil or non-nil). +func WithInlineTest(i InlineTest) Opt { + return func(d *Differ) { d.inline = i } +} + +// InlineTest is the value and options passed to WithInlineTest. Only T is +// required, and notice: it's the deep.T interface, and standard library +// testing.T satisfies this interface. (The deep.T interface is necessary +// to test the testing.) +type InlineTest struct { + T T + Fatal bool // call T.Error by default +} + +type Differ struct { + compareFunctions bool + compareUnexportedFields bool + floatPrecision uint + ignoreSliceOrder bool + logErrors bool + maxDepth uint + maxDiff uint + nilMapsAreEmpty bool + nilPointersAreZero bool + nilSlicesAreEmpty bool + inline InlineTest +} + +func NewDiffer(opts ...Opt) Differ { + d := Differ{ + // options where zero-value equals default value are omitted + floatPrecision: 10, + maxDiff: 10, + } + for opt := range opts { + opts[opt](&d) + } + return d +} + +type Diff []string + +func (d Diff) Equal(other Diff) bool { + if len(d) != len(other) { + return false + } + for i := range d { + if d[i] != other[i] { + return false + } + } + return true +} + +func (d Diff) ToSlice() []string { + return d +} + +// Compare compares variables a and b, recursing into their structure up to +// MaxDepth levels deep (if greater than zero), and returns a list of differences, +// or nil if there are none. Some differences may not be found if an error is +// also returned. +// +// If a type has an Equal method, like time.Equal, it is called to check for +// equality. +// +// When comparing a struct, if a field has the tag `deep:"-"` then it will be +// ignored. +func (d Differ) Diff(a, b any) Diff { + t := d.inline.T + if t != nil { + t.Helper() + } + + aVal := reflect.ValueOf(a) + bVal := reflect.ValueOf(b) + c := &cmp{ + conf: d, + diff: []string{}, + buff: []string{}, + floatFormat: fmt.Sprintf("%%.%df", d.floatPrecision), + } + + // Can bypass full diff if both or only one operand is nil + if a == nil && b == nil { + return nil + } else if a == nil && b != nil { + c.saveDiff("", b) + goto ret + } else if a != nil && b == nil { + c.saveDiff(a, "") + goto ret + } + + c.equals(aVal, bVal, 0) // full diff + +ret: + if len(c.diff) > 0 { + if t != nil { + if d.inline.Fatal { + t.Fatal(c.diff) + } else { + t.Error(c.diff) + } + } + return c.diff // diffs + } + + return nil // no diffs +} + +// T is the subset of standard library testing.T that option WithInlineTest needs. +type T interface { + Error(args ...any) + Fatal(args ...any) + Helper() +} diff --git a/differ_test.go b/differ_test.go new file mode 100644 index 0000000..ba3fb46 --- /dev/null +++ b/differ_test.go @@ -0,0 +1,106 @@ +package deep_test + +import ( + "testing" + + "github.com/go-test/deep" +) + +func TestDiffer(t *testing.T) { + d := deep.NewDiffer() + + diff1 := d.Diff("foo", "bar") + if diff1 == nil { + t.Error("no diff") + } + + s := diff1.ToSlice() + if len(s) != 1 { + t.Errorf("got slice len %d, expected 1", len(s)) + } + + diff2 := d.Diff("foo", "bar") + if diff2 == nil { + t.Error("no diff") + } + + // diff1 and diff2 are equal (i.e. same diff: foo != bar) + eq := diff1.Equal(diff2) + if eq != true { + t.Error("diffs not equal") + } + + // diff1 and diff3 are NOT equal + diff3 := d.Diff("a", "b") + if diff3 == nil { + t.Error("should not be equal") + } + eq = diff1.Equal(diff3) + if eq == true { + t.Error("diffs should not be equal") + } + + // diff4 isn't a diff, so it's not equal to diff1 + diff4 := d.Diff("ok", "ok") + if diff4 != nil { + t.Error("should be equal") + } + eq = diff1.Equal(diff4) + if eq == true { + t.Error("should not be equal") + } +} + +type mockT struct { + calledError bool + calledFatal bool + calledHelper bool +} + +func (t *mockT) Error(args ...any) { t.calledError = true } +func (t *mockT) Fatal(args ...any) { t.calledFatal = true } +func (t *mockT) Helper() { t.calledHelper = true } + +func TestDiffer_WithInlineTest(t *testing.T) { + // No diff, no calls to T except Helper + m := &mockT{} + d := deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m})) + d.Diff("foo", "foo") + if m.calledHelper == false { + t.Errorf("did not call T.Helper") + } + if m.calledError == true { + t.Errorf("called T.Error") + } + if m.calledFatal == true { + t.Errorf("called T.Fatal") + } + + // Diff and by default Error, not Fatal, is called + m = &mockT{} + d = deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m})) + d.Diff("foo", "bar") + if m.calledHelper == false { + t.Errorf("did not call T.Helper") + } + if m.calledError != true { + t.Errorf("did not call T.Error") + } + if m.calledFatal == true { + t.Errorf("called T.Fatal") + } + + // Diff and Fatal + m = &mockT{} + d = deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m, Fatal: true})) + d.Diff("foo", "bar") + if m.calledHelper == false { + t.Errorf("did not call T.Helper") + } + if m.calledError == true { + t.Errorf("called T.Error") + } + if m.calledFatal != true { + t.Errorf("did not call T.Fatal") + } +} diff --git a/test/coverage b/test/coverage index e328944..ae5376c 100755 --- a/test/coverage +++ b/test/coverage @@ -1,3 +1,5 @@ #!/bin/sh +export GTK_MODULES="" # avoid Ubuntu/Firefox warning go test -coverprofile=coverage.out "$@" -go tool cover -html=coverage.out +go tool cover -html=coverage.out -o coverage.html +open coverage.html