From d5e30c26fea758e4752727f5ffddefa846fef583 Mon Sep 17 00:00:00 2001 From: Jordan Orelli Date: Sun, 2 Aug 2020 17:19:46 +0000 Subject: [PATCH] re-implementing the env system the env system code is unecessarily fiddly. The current env system is the second env system, so the layer system will be the third implementation of the env system. the environment in which we run our tests is a layered environment like the scope in a programming language, but unlike with nested closures, tea permits the user to supply match tags to skip what would be stack frames in a system based on nested closures. It's more easily understood as being a system of layers, and that we skip non-matching layers. the env system also had the problem that it was a tree that was dynamically constructed as a side-effect of the execution tree, but each chain only needs to read its own ancestry in the environment tree. Having a single tree poses challenges for parellelizing the execution of different test chains, so instead, I would rather keep a list of layers for each chain, instead of keeping a single environment tree that is shared across all chains in the run. Additionally, the env system was sparse; tests that save no data put no nodes in the env tree. I would like to be able to display to users the layers available to their test in certain error cases so that they can understand why a match may have failed. Right now, in practice, encountering a match failure in a test plan is confusing to understand. --- assertions_test.go | 28 +++++++++++++- chain.go | 7 ++++ env.go | 2 +- error.go | 6 ++- gen_test.go | 34 +++++++++++++++++ layer.go | 59 ++++++++++++++++++++++++++++ layer_test.go | 95 ++++++++++++++++++++++++++++++++++++++++++++++ tree.go | 55 ++++++++++++++++++++++++--- 8 files changed, 276 insertions(+), 10 deletions(-) create mode 100644 chain.go create mode 100644 gen_test.go create mode 100644 layer.go create mode 100644 layer_test.go diff --git a/assertions_test.go b/assertions_test.go index 6d44e00..7a27ab6 100644 --- a/assertions_test.go +++ b/assertions_test.go @@ -1,6 +1,12 @@ package tea -import "testing" +// this is just a collection of ... reusable assertions for the unit tests for +// tea itself. + +import ( + "errors" + "testing" +) type labelChecker struct { name string @@ -46,3 +52,23 @@ func (l *labelChecker) report(t *testing.T) { } } } + +func assertError(t *testing.T, fatal bool, err error, target error) { + if !errors.Is(err, target) { + if fatal { + t.Fatalf("expected error to be %s, instead found: %s", target, err) + } else { + t.Errorf("expected error to be %s, instead found: %s", target, err) + } + } +} + +func assertNoError(t *testing.T, fatal bool, err error) { + if err != nil { + if fatal { + t.Fatalf("encountered unexpected error: %v", err) + } else { + t.Fatalf("encountered unexpected error: %v", err) + } + } +} diff --git a/chain.go b/chain.go new file mode 100644 index 0000000..8bb8ef7 --- /dev/null +++ b/chain.go @@ -0,0 +1,7 @@ +package tea + +// xchain is a chain of xnodes. an xchain is an execution plan for executing a +// sequence of tests. somwhat ironically the nodes are actually in a slice. +type xchain struct { + xnodes []*xnode +} diff --git a/env.go b/env.go index 9df85fb..4700962 100644 --- a/env.go +++ b/env.go @@ -60,7 +60,7 @@ func (e *env) save(test Test) *env { saved := make(map[string]interface{}) for i := 0; i < T.NumField(); i++ { f := T.Field(i) - if !isSaveField(f) { + if save, _ := isSaveField(f); !save { continue } diff --git a/error.go b/error.go index 27644e5..cb5500c 100644 --- a/error.go +++ b/error.go @@ -4,5 +4,7 @@ type testError string func (e testError) Error() string { return string(e) } -const PlanError = testError("test plan error") -const RunError = testError("test run error") +const ( + PlanError = testError("test plan error") + RunError = testError("test run error") +) diff --git a/gen_test.go b/gen_test.go new file mode 100644 index 0000000..a8c5018 --- /dev/null +++ b/gen_test.go @@ -0,0 +1,34 @@ +package tea + +// generator functions for creating randomized test data. + +import ( + "math/rand" + "time" +) + +// alpha is an alphabet of letters to pick from when producing random strings. +// the selected characters are human-readable without much visual ambiguity and +// include some code points beyond the ascii range to make sure things don't +// break on unicode input. +var alpha = []rune("" + + // lower-case ascii letters + "abcdefghjkmnpqrstwxyz" + + // upper-case ascii letters + "ABCDEFGHIJKLMNPQRSTWXYZ" + + // some digits + "23456789" + + // miscellaneous non-ascii characters + "¢£¥ÐÑØæñþÆŁřƩλЖд") + +func rstring(n int) string { + r := make([]rune, n) + for i, _ := range r { + r[i] = alpha[rand.Intn(len(alpha))] + } + return string(r) +} + +func init() { + rand.Seed(time.Now().Unix()) +} diff --git a/layer.go b/layer.go new file mode 100644 index 0000000..9eef420 --- /dev/null +++ b/layer.go @@ -0,0 +1,59 @@ +package tea + +import "reflect" + +type sval struct { + name string + val interface{} +} + +// layerData is an ordered list of key-value pairs. We preserve the ordering of +// the fields from the structs that produced the layer so that viewing a list +// of layers shows each layer from the sam struct in the same value-order, +// which is the order those fields appear in their originating stuct (and not, +// like, alphabetical order). Although a bit more tedious to work with +// internally, this is intended to make it possible to write more descriptive, +// easily understood PlanError messages. +type layerData []sval + +func (l layerData) get(key string) (val interface{}, present bool) { + for _, pair := range l { + if pair.name == key { + return pair.val, true + } + } + return nil, false +} + +type layer struct { + origin *xnode + saved layerData +} + +func makeLayerData(test Test) (layerData, error) { + V := reflect.ValueOf(test) + if V.Type().Kind() == reflect.Ptr { + V = V.Elem() + } + T := V.Type() + + if T.Kind() != reflect.Struct { + return nil, nil + } + + fields, err := getSaveFields(T) + if err != nil { + return nil, err + } + if len(fields) == 0 { + // is this weird? maybe this is weird. + return nil, nil + } + + data := make(layerData, 0, len(fields)) + for _, f := range fields { + fv := V.FieldByName(f.Name).Interface() + data = append(data, sval{name: f.Name, val: fv}) + } + return data, nil +} diff --git a/layer_test.go b/layer_test.go new file mode 100644 index 0000000..09d22c9 --- /dev/null +++ b/layer_test.go @@ -0,0 +1,95 @@ +package tea + +import ( + "math/rand" + "testing" +) + +func TestLayerData(t *testing.T) { + t.Run("non-struct tests produce empty layers", func(t *testing.T) { + lr, err := makeLayerData(Pass) + if len(lr) != 0 { + t.Errorf("expected a nil layer but saw %v instead", lr) + } + if err != nil { + t.Errorf("expected no error from lay but saw %v instead", err) + } + }) + + t.Run("save tags on unexported fields are plan errors", func(t *testing.T) { + type T struct { + Passing + count int `tea:"save"` + } + _, err := makeLayerData(T{}) + assertError(t, true, err, PlanError) + }) + + t.Run("mixed exported/unexported fields still an error", func(t *testing.T) { + type T struct { + Passing + count int `tea:"save"` + Bar string `tea:"save"` + } + _, err := makeLayerData(T{}) + assertError(t, true, err, PlanError) + }) + + t.Run("mixed exported/unexported fields still an error", func(t *testing.T) { + type T struct { + Passing + count int `tea:"save"` + bar string `tea:"save"` + } + _, err := makeLayerData(T{}) + assertError(t, true, err, PlanError) + }) + + t.Run("save one int", func(t *testing.T) { + type T struct { + Passing + Count int `tea:"save"` + } + test := T{Count: rand.Int()} + data, err := makeLayerData(test) + assertNoError(t, true, err) + if len(data) == 0 { + t.Fatalf("expected nonempty layer, saw empty layer instead") + } + if v, ok := data.get("Count"); !ok { + t.Errorf("layer data is missing expected field Count") + } else { + if v != test.Count { + t.Errorf("layer data expected Count value of %d but saw %d instead", test.Count, v) + } + } + }) + + t.Run("an int and a string", func(t *testing.T) { + type T struct { + Passing + Count int `tea:"save"` + Name string `tea:"save"` + } + test := T{Count: rand.Int(), Name: rstring(8)} + data, err := makeLayerData(test) + assertNoError(t, true, err) + if len(data) == 0 { + t.Fatalf("expected nonempty layer, saw empty layer instead") + } + if v, ok := data.get("Count"); !ok { + t.Errorf("layer data is missing expected field Count") + } else { + if v != test.Count { + t.Errorf("layer data expected Count value of %d but saw %d instead", test.Count, v) + } + } + if v, ok := data.get("Name"); !ok { + t.Errorf("layer data is missing expected field Count") + } else { + if v != test.Name { + t.Errorf("layer data expected Name value of %s but saw %s instead", test.Name, v) + } + } + }) +} diff --git a/tree.go b/tree.go index 684557a..f1a898e 100644 --- a/tree.go +++ b/tree.go @@ -1,6 +1,8 @@ package tea import ( + "errors" + "fmt" "reflect" "strings" "testing" @@ -100,20 +102,61 @@ func (t *Tree) Child(test Test) *Tree { return child } +func isExported(f reflect.StructField) bool { + // PkgPath is the package path that qualifies a lower case (unexported) + // field name. It is empty for upper case (exported) field names. + // See https://golang.org/ref/spec#Uniqueness_of_identifiers + return f.PkgPath == "" +} + // isSaveField takes a struct field and checks its tags for a save tag, // indicating that the field's value should persist between tests -func isSaveField(f reflect.StructField) bool { - // PkgPath is empty string when the identifier is unexported. - if f.PkgPath != "" { - return false +func isSaveField(f reflect.StructField) (bool, error) { + if !isExported(f) { + return false, errors.New("unexported field cannot be marked as save field") } + parts := strings.Split(f.Tag.Get("tea"), ",") for _, part := range parts { if part == "save" { - return true + return true, nil } } - return false + return false, nil +} + +func getSaveFields(t reflect.Type) ([]reflect.StructField, error) { + type fieldError struct { + fieldName string + err error + } + var errs []fieldError + + var fields []reflect.StructField + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + save, err := isSaveField(f) + if err != nil { + errs = append(errs, fieldError{fieldName: f.Name, err: err}) + continue + } + if save { + fields = append(fields, f) + } + } + + switch len(errs) { + case 0: + return fields, nil + case 1: + return nil, fmt.Errorf("%w: unable to read save field %s in %s: %v", PlanError, errs[0].fieldName, t.Name(), errs[0].err) + default: + messages := make([]string, 0, len(errs)) + for _, fe := range errs { + messages = append(messages, fmt.Sprintf("{%s: %v}", fe.fieldName, fe.err)) + } + return nil, fmt.Errorf("%w: save error encountered in %d fields of %s: [%s]", PlanError, len(errs), t.Name(), strings.Join(messages, ", ")) + } } // isLoadField takes a struct field and checks its tags for a load tag,