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,