Compare commits

...

9 Commits

Author SHA1 Message Date
Jordan Orelli d5e30c26fe 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.
4 years ago
Jordan Orelli df36c8073c select the middle of a graph 4 years ago
Jordan Orelli 00ba88a520 test xleaves 4 years ago
Jordan Orelli 31fa2eb711 clean up some redundant test code 4 years ago
Jordan Orelli 500b02db34 graph-building seems to be working 4 years ago
Jordan Orelli 4d5a33dbdb bit of refactoring
actually made it longer but it's easier to read now. also the xnodes are
now directly inside of the lnodes, removing a bit of pointer
indirection.
4 years ago
Jordan Orelli 75849b2f36 testing selections a bit 4 years ago
Jordan Orelli 8659b230c8 just build both graphs at the same time. 4 years ago
Jordan Orelli acb92cc4d6 i dunno if this is a good idea. 4 years ago

@ -0,0 +1,74 @@
package tea
// this is just a collection of ... reusable assertions for the unit tests for
// tea itself.
import (
"errors"
"testing"
)
type labelChecker struct {
name string
wanted map[string]bool // all the strings we want
found map[string]bool // all the strings we've found
}
func wantStrings(name string, want ...string) labelChecker {
l := newLabelChecker(name)
l.want(want...)
return l
}
func newLabelChecker(name string) labelChecker {
return labelChecker{
name: name,
wanted: make(map[string]bool),
found: make(map[string]bool),
}
}
func (l *labelChecker) want(names ...string) {
for _, name := range names {
l.wanted[name] = true
}
}
func (l *labelChecker) add(name string) {
l.found[name] = true
}
func (l *labelChecker) report(t *testing.T) {
for name, _ := range l.found {
if l.wanted[name] {
t.Logf("%s saw expected value %s", l.name, name)
} else {
t.Errorf("%s saw unexpected value %s", l.name, name)
}
}
for name, _ := range l.wanted {
if !l.found[name] {
t.Errorf("%s missing expected value %s", l.name, name)
}
}
}
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)
}
}
}

@ -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
}

@ -0,0 +1,33 @@
package tea
// these constant tests are useful for testing things that manipulate the
// graph.
const (
A = Passing("A")
B = Passing("B")
C = Passing("C")
D = Passing("D")
E = Passing("E")
F = Passing("F")
G = Passing("G")
H = Passing("H")
I = Passing("I")
J = Passing("J")
K = Passing("K")
L = Passing("L")
M = Passing("M")
N = Passing("N")
O = Passing("O")
P = Passing("P")
Q = Passing("Q")
R = Passing("R")
S = Passing("S")
T = Passing("T")
U = Passing("U")
V = Passing("V")
W = Passing("W")
X = Passing("X")
Y = Passing("Y")
Z = Passing("Z")
)

@ -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
}

@ -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")
)

@ -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())
}

@ -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
}

@ -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)
}
}
})
}

@ -0,0 +1,158 @@
package tea
import "fmt"
var lastID int
func nextNodeID() int {
lastID++
return lastID
}
// lnode is a node in the logical graph. Developers create a logical graph in
// which nodes may have more than one parent. Each test value written by a
// developer appears as one logical node in the logical graph. The public
// documentation refers to the logical graph as simply the test graph.
type lnode struct {
id int
name string
xnodes []xnode
test Test
parents []*lnode
children []*lnode
}
// newLNode greates a new lnode with the provided test and the list of parents.
// If there are no parents, the lnode is a root node, and the provided test is
// run once. Otherwise, the provided test is used to create xnodes that are
// children of all of the provided parent nodes' xnodes.
func newLNode(test Test, sel Selection) *lnode {
if len(sel.nodes) == 0 {
return rootLNode(test)
}
node := lnode{
id: nextNodeID(),
name: parseName(test),
test: test,
parents: make([]*lnode, len(sel.nodes)),
}
// not sure if this copy is necessary
copy(node.parents, sel.nodes)
xID := 0
for _, parent := range node.parents {
parent.children = append(parent.children, &node)
for i, _ := range parent.xnodes {
x := xnode{
id: xID,
lnode: &node,
parent: &parent.xnodes[i],
}
node.xnodes = append(node.xnodes, x)
xID++
}
}
for i, x := range node.xnodes {
x.parent.children = append(x.parent.children, &node.xnodes[i])
}
return &node
}
// rootLNode creates a root lnode. This case is a lot simpler so I split it out
// to keep newLNode a little more readable.
func rootLNode(test Test) *lnode {
id := nextNodeID()
node := lnode{
id: id,
name: parseName(test),
test: test,
}
node.xnodes = []xnode{{id: 0, lnode: &node}}
return &node
}
// xnode is a node in the execution graph, representing one instance of a test
// to be executed. xnode is the unit test in tea. every xnode is either
// unparented or has one parent.
type xnode struct {
id int // id within the parent lnode
lnode *lnode // corresponding node in the logical test graph
parent *xnode
children []*xnode
}
func (x *xnode) isOnlyTestInLNode() bool {
return len(x.lnode.xnodes) == 1
}
// label must be unique or some other shit will break, I'm using this as a way
// to globally identify xnodes, which may be very flawed and maybe I should
// have an actual global ID system.
func (x *xnode) label() string {
if x.parent == nil {
switch {
case len(x.lnode.children) < 10:
return fmt.Sprintf("%s.%d", x.lnode.name, x.id)
case len(x.lnode.children) < 100:
return fmt.Sprintf("%s.%02d", x.lnode.name, x.id)
case len(x.lnode.children) < 1000:
return fmt.Sprintf("%s.%03d", x.lnode.name, x.id)
default:
return fmt.Sprintf("%s.%04d", x.lnode.name, x.id)
}
} else {
switch {
case len(x.lnode.children) < 10:
return fmt.Sprintf("%s.%d.%s", x.lnode.name, x.id, x.parent.lnode.name)
case len(x.lnode.children) < 100:
return fmt.Sprintf("%s.%02d.%s", x.lnode.name, x.id, x.parent.lnode.name)
case len(x.lnode.children) < 1000:
return fmt.Sprintf("%s.%03d.%s", x.lnode.name, x.id, x.parent.lnode.name)
default:
return fmt.Sprintf("%s.%04d.%s", x.lnode.name, x.id, x.parent.lnode.name)
}
}
}
// ancestry gives a slice of xnodes beginning at the root of the x graph and
// terminating at the receiver xnode. The ancestry list of a leaf node in the x
// graph is a single chain of tests.
func (x *xnode) ancestry() []*xnode {
if x.parent == nil {
return []*xnode{x}
}
return append(x.parent.ancestry(), x)
}
// descendents gives a slice of all xnodes whose ancestry includes the receiver
// xnode, in depth-first order.
func (x *xnode) descendents() []*xnode {
if len(x.children) == 0 {
return nil
}
descendents := make([]*xnode, 0, len(x.children))
for _, c := range x.children {
descendents = append(descendents, c)
descendents = append(descendents, c.descendents()...)
}
return descendents
}
// leaves descends the x graph from the receiver xnode, returning a slice
// containing all of the leaves of the x graph having the receiver x as an
// ancestor.
func (x *xnode) leaves() []*xnode {
if len(x.children) == 0 {
return []*xnode{x}
}
var leaves []*xnode
for _, child := range x.children {
leaves = append(leaves, child.leaves()...)
}
return leaves
}

@ -0,0 +1,8 @@
package tea
import (
"testing"
)
func TestXLabels(t *testing.T) {
}

@ -0,0 +1,73 @@
package tea
func NewSelection(test Test) Selection {
node := newLNode(test, Selection{})
return Selection{nodes: []*lnode{node}}
}
// Selection represents a set of nodes in our graph.
type Selection struct {
nodes []*lnode
}
func (s Selection) Child(test Test) Selection {
node := newLNode(test, s)
return Selection{nodes: []*lnode{node}}
}
func (s Selection) And(other Selection) Selection {
included := make(map[int]bool)
out := make([]*lnode, 0, len(s.nodes)+len(other.nodes))
for _, n := range append(s.nodes, other.nodes...) {
if !included[n.id] {
out = append(out, n)
included[n.id] = true
}
}
return Selection{nodes: out}
}
// xnodes represents all xnodes in the selected lnodes
func (s Selection) xnodes() []*xnode {
xnodes := make([]*xnode, 0, s.countXNodes())
for _, L := range s.nodes {
for i, _ := range L.xnodes {
xnodes = append(xnodes, &L.xnodes[i])
}
}
return xnodes
}
func (s Selection) countXNodes() int {
total := 0
for _, child := range s.nodes {
total += len(child.xnodes)
}
return total
}
// xleaves looks at all of the selected xnodes, and for every selected xnode,
// traverses the x graph until we arrive at the set of all leaf nodes that have
// a selected ancestor. If the selection consists of the root node, the xleaves
// are all of the leaves of the x graph.
func (s *Selection) xleaves() []*xnode {
// honestly think that by definition every xnode in the selection has a
// non-overlapping set of leaves but thinking about this shit is extremely
// starting to hurt my brain so I'm going to write this in a way that's
// maybe very redundant.
seen := make(map[string]bool)
var leaves []*xnode
for _, x := range s.xnodes() {
for _, leaf := range x.leaves() {
if seen[leaf.label()] {
panic("double-counting leaves somehow")
}
seen[leaf.label()] = true
leaves = append(leaves, leaf)
}
}
return leaves
}

@ -0,0 +1,219 @@
package tea
import (
"testing"
)
type selectionTest struct {
label string
selection Selection
lnodes []string
xnodes []string
xleaves []string
}
func (test *selectionTest) Run(t *testing.T) {
LWanted := wantStrings("selected lnode names", test.lnodes...)
for _, L := range test.selection.nodes {
LWanted.add(L.name)
}
LWanted.report(t)
XWanted := wantStrings("selected xnode labels", test.xnodes...)
for _, X := range test.selection.xnodes() {
XWanted.add(X.label())
}
XWanted.report(t)
XLeavesWanted := wantStrings("leaf xnode labels", test.xleaves...)
for _, X := range test.selection.xnodes() {
for _, leaf := range X.leaves() {
XLeavesWanted.add(leaf.label())
}
}
XLeavesWanted.report(t)
}
func TestSelections(t *testing.T) {
tests := []selectionTest{
{
label: "new selection",
selection: NewSelection(A),
lnodes: []string{"A"},
xnodes: []string{"A.0"},
xleaves: []string{"A.0"},
},
{
label: "root with one child",
selection: NewSelection(A).Child(B),
lnodes: []string{"B"},
xnodes: []string{"B.0.A"},
xleaves: []string{"B.0.A"},
},
{
label: "two selected roots",
selection: NewSelection(A).And(NewSelection(B)),
lnodes: []string{"A", "B"},
xnodes: []string{"A.0", "B.0"},
xleaves: []string{"A.0", "B.0"},
},
}
add := func(fn func() selectionTest) { tests = append(tests, fn()) }
add(func() selectionTest {
root := NewSelection(A)
b := root.Child(B)
return selectionTest{
label: "root and child selected",
selection: root.And(b),
lnodes: []string{"A", "B"},
xnodes: []string{"A.0", "B.0.A"},
xleaves: []string{"B.0.A"},
}
})
add(func() selectionTest {
root := NewSelection(A)
b := root.Child(B)
return selectionTest{
label: "an optional test",
selection: root.And(b).Child(C),
lnodes: []string{"C"},
xnodes: []string{"C.0.A", "C.1.B"},
xleaves: []string{"C.0.A", "C.1.B"},
}
})
add(func() selectionTest {
root := NewSelection(A)
b := root.Child(B)
c := root.Child(C)
return selectionTest{
label: "two children selected",
selection: b.And(c),
lnodes: []string{"B", "C"},
xnodes: []string{"B.0.A", "C.0.A"},
xleaves: []string{"B.0.A", "C.0.A"},
}
})
add(func() selectionTest {
root := NewSelection(A)
b := root.Child(B)
c := root.Child(C)
return selectionTest{
label: "a diamond test",
selection: b.And(c).Child(D),
lnodes: []string{"D"},
xnodes: []string{"D.0.B", "D.1.C"},
xleaves: []string{"D.0.B", "D.1.C"},
}
})
add(func() selectionTest {
root := NewSelection(A)
b := root.Child(B)
c := root.Child(C)
d := b.And(c).Child(D)
return selectionTest{
label: "child of a node having multiple parents",
selection: d.Child(E),
lnodes: []string{"E"},
xnodes: []string{"E.0.D", "E.1.D"},
xleaves: []string{"E.0.D", "E.1.D"},
}
})
add(func() selectionTest {
root := NewSelection(A)
b := root.Child(B)
c := root.Child(C)
d := b.And(c).Child(D)
d.Child(E)
return selectionTest{
label: "the root of a complex graph",
selection: root,
lnodes: []string{"A"},
xnodes: []string{"A.0"},
xleaves: []string{"E.0.D", "E.1.D"},
}
})
// A
// / \
// / \
// B C
// / \ / \
// / \ / \
// D E F
// / \ / \
// / \ / \
// G H I
// | | |
// | | |
// J K L
// | \ /
// | \ /
// M N
//
add(func() selectionTest {
root := NewSelection(A)
b := root.Child(B)
c := root.Child(C)
b.Child(D)
e := b.And(c).Child(E)
f := c.Child(F)
e.Child(G).Child(J).Child(M)
h := e.And(f).Child(H)
l := f.Child(I).Child(L)
k := h.Child(K)
k.And(l).Child(N)
return selectionTest{
label: "criss-crossing",
selection: root,
lnodes: []string{"A"},
xnodes: []string{"A.0"},
xleaves: []string{
"D.0.B", // A B D
"M.0.J", // A B E G J M
"M.1.J", // A C E G J M
"N.0.K", // A B E H K N
"N.1.K", // A C E H K N
"N.2.K", // A C F H K N
"N.3.L", // A C F I L N
},
}
})
add(func() selectionTest {
root := NewSelection(A)
b := root.Child(B)
c := root.Child(C)
b.Child(D)
e := b.And(c).Child(E)
f := c.Child(F)
e.Child(G).Child(J).Child(M)
h := e.And(f).Child(H)
l := f.Child(I).Child(L)
k := h.Child(K)
k.And(l).Child(N)
return selectionTest{
label: "criss-crossing-partial",
selection: e,
lnodes: []string{"E"},
xnodes: []string{"E.0.B", "E.1.C"},
xleaves: []string{
"M.0.J", // A B E G J M
"M.1.J", // A C E G J M
"N.0.K", // A B E H K N
"N.1.K", // A C E H K N
},
}
})
for _, test := range tests {
t.Run(test.label, test.Run)
}
}

@ -12,6 +12,22 @@ type Test interface {
Run(*testing.T)
}
// clone clones a test value, yielding a new test value that can be executed
// and mutated such that the original is not mutated. Tests containing pointers
// to objects that were not created by tea will probably not work right. That's
// like, kinda on you though, I can't really enforce things that the Go type
// system doesn't let me enforce.
func clone(t Test) Test {
v := reflect.ValueOf(t)
switch v.Kind() {
case reflect.Ptr:
v = v.Elem()
}
destV := reflect.New(v.Type())
destV.Elem().Set(v)
return destV.Interface().(Test)
}
// After defines the interface used for performing test cleanup. If a Test
// value also implements After, that test's After method will be called after
// all tests are run. Tests in a sequence will have their After methods called
@ -41,6 +57,7 @@ const Pass = Passing("test passed")
type Passing string
func (p Passing) Run(t *testing.T) {}
func (p Passing) String() string { return string(p) }
// parseName parses the name for a given test
func parseName(test Test) string {

@ -1,6 +1,8 @@
package tea
import (
"errors"
"fmt"
"reflect"
"strings"
"testing"
@ -100,29 +102,61 @@ func (t *Tree) Child(test Test) *Tree {
return child
}
// clone clones a test value, yielding a new test value that can be executed
// and mutated such that the original is not mutated.
func clone(t Test) Test {
srcV := reflect.ValueOf(t).Elem()
destV := reflect.New(srcV.Type())
destV.Elem().Set(srcV)
return destV.Interface().(Test)
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,

Loading…
Cancel
Save