You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
579 lines
13 KiB
Go
579 lines
13 KiB
Go
package tea
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func assertErrorType(t *testing.T, err error, target error) {
|
|
if !errors.Is(err, target) {
|
|
t.Errorf("expected %v, instead saw %v", target, err)
|
|
} else {
|
|
t.Logf("found expected %v: %v", target, err)
|
|
}
|
|
}
|
|
|
|
func TestSave(t *testing.T) {
|
|
t.Run("empty begets nil", func(t *testing.T) {
|
|
e := mkenv(Pass)
|
|
if e != nil {
|
|
t.Errorf("saw unexpected env value looking for nil: %v", e)
|
|
}
|
|
})
|
|
|
|
t.Run("unexported fields are ignored", func(t *testing.T) {
|
|
type test struct {
|
|
Passing
|
|
foo int `tea:"save"`
|
|
}
|
|
|
|
if e := mkenv(test{foo: 5}); e != nil {
|
|
t.Errorf("saw unexpected env value looking for nil: %v", e)
|
|
}
|
|
})
|
|
|
|
t.Run("create an env from a test", func(t *testing.T) {
|
|
test := struct {
|
|
Passing
|
|
Foo int `tea:"save"`
|
|
}{
|
|
Foo: 5,
|
|
}
|
|
|
|
e := mkenv(&test)
|
|
if e == nil {
|
|
t.Fatalf("saw nil env when expecting a valid env")
|
|
}
|
|
|
|
foo, ok := e.data["Foo"]
|
|
if !ok {
|
|
t.Errorf("expected field Foo to be saved but was not saved")
|
|
}
|
|
|
|
if foo != 5 {
|
|
t.Errorf("expected value %v but saw %v instead", 5, foo)
|
|
}
|
|
})
|
|
|
|
t.Run("update an existing env", func(t *testing.T) {
|
|
test := struct {
|
|
Passing
|
|
Foo int `tea:"save"`
|
|
}{
|
|
Foo: 5,
|
|
}
|
|
|
|
e := mkenv(&test)
|
|
if e == nil {
|
|
t.Fatalf("saw nil env when expecting a valid env")
|
|
}
|
|
|
|
foo, ok := e.data["Foo"]
|
|
if !ok {
|
|
t.Errorf("expected field Foo to be saved but was not saved")
|
|
}
|
|
|
|
if foo != 5 {
|
|
t.Errorf("expected value %v but saw %v instead", 5, foo)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestLoad(t *testing.T) {
|
|
t.Run("load an int", func(t *testing.T) {
|
|
e := &env{
|
|
data: map[string]interface{}{"Foo": 5},
|
|
}
|
|
|
|
var test struct {
|
|
Passing
|
|
Foo int `tea:"load"`
|
|
}
|
|
|
|
if err := e.load(&test); err != nil {
|
|
t.Errorf("unexpected load error: %v", err)
|
|
}
|
|
if test.Foo != 5 {
|
|
t.Errorf("expected value %v but saw %v instead", 5, test.Foo)
|
|
}
|
|
})
|
|
|
|
t.Run("loads can fail", func(t *testing.T) {
|
|
e := &env{
|
|
data: map[string]interface{}{"NotFoo": 5},
|
|
}
|
|
|
|
var test struct {
|
|
Passing
|
|
Foo int `tea:"load"`
|
|
}
|
|
|
|
if err := e.load(&test); err == nil {
|
|
t.Fatalf("expected a load error but did not see one")
|
|
} else {
|
|
assertErrorType(t, err, PlanError)
|
|
}
|
|
})
|
|
|
|
t.Run("skip load if field is already set", func(t *testing.T) {
|
|
e := &env{
|
|
data: map[string]interface{}{"Foo": 3},
|
|
}
|
|
|
|
var test struct {
|
|
Passing
|
|
Foo int `tea:"load"`
|
|
}
|
|
test.Foo = 5
|
|
|
|
if err := e.load(&test); err != nil {
|
|
t.Errorf("unexpected load error: %v", err)
|
|
}
|
|
if test.Foo != 5 {
|
|
t.Errorf("load overwrote expected value of 5 with %d", test.Foo)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMatch(t *testing.T) {
|
|
t.Run("required match field not present", func(t *testing.T) {
|
|
e := &env{
|
|
data: map[string]interface{}{"Foo": 5},
|
|
}
|
|
|
|
var test struct {
|
|
Passing
|
|
Name string `tea:"match"`
|
|
Foo int `tea:"load"`
|
|
}
|
|
|
|
if err := e.load(&test); err == nil {
|
|
t.Errorf("expected a load error but did not see one")
|
|
} else {
|
|
assertErrorType(t, err, PlanError)
|
|
}
|
|
})
|
|
|
|
t.Run("required match field has wrong value", func(t *testing.T) {
|
|
e := &env{
|
|
data: map[string]interface{}{
|
|
"Foo": 5,
|
|
"Name": "alice",
|
|
},
|
|
}
|
|
|
|
var test struct {
|
|
Passing
|
|
Name string `tea:"match"`
|
|
Foo int `tea:"load"`
|
|
}
|
|
test.Name = "bob"
|
|
|
|
if err := e.load(&test); err == nil {
|
|
t.Errorf("expected a load error but did not see one")
|
|
} else {
|
|
assertErrorType(t, err, RunError)
|
|
}
|
|
})
|
|
|
|
t.Run("required match field has wrong type", func(t *testing.T) {
|
|
e := &env{
|
|
data: map[string]interface{}{
|
|
"Foo": 5,
|
|
"Name": []byte("alice"),
|
|
},
|
|
}
|
|
|
|
var test struct {
|
|
Passing
|
|
Name string `tea:"match"`
|
|
Foo int `tea:"load"`
|
|
}
|
|
test.Name = "bob"
|
|
|
|
if err := e.load(&test); err == nil {
|
|
t.Errorf("expected a load error but did not see one")
|
|
} else {
|
|
assertErrorType(t, err, PlanError)
|
|
}
|
|
})
|
|
|
|
t.Run("simple match", func(t *testing.T) {
|
|
e := &env{
|
|
data: map[string]interface{}{
|
|
"Foo": 5,
|
|
"Name": "alice",
|
|
},
|
|
}
|
|
|
|
var test struct {
|
|
Passing
|
|
Name string `tea:"match"`
|
|
Foo int `tea:"load"`
|
|
}
|
|
test.Name = "alice"
|
|
|
|
if err := e.load(&test); err != nil {
|
|
t.Errorf("unexpected load error: %v", err)
|
|
}
|
|
if test.Foo != 5 {
|
|
t.Errorf("expected Foo to load 5 but is %d instead", test.Foo)
|
|
}
|
|
})
|
|
|
|
t.Run("ancestor match", func(t *testing.T) {
|
|
e := &env{
|
|
data: map[string]interface{}{
|
|
"Foo": 3,
|
|
"Name": "bob",
|
|
},
|
|
parent: &env{
|
|
data: map[string]interface{}{
|
|
"Foo": 5,
|
|
"Name": "alice",
|
|
},
|
|
},
|
|
}
|
|
|
|
var test struct {
|
|
Passing
|
|
Name string `tea:"match"`
|
|
Foo int `tea:"load"`
|
|
}
|
|
test.Name = "alice"
|
|
|
|
if err := e.load(&test); err != nil {
|
|
t.Errorf("unexpected load error: %v", err)
|
|
}
|
|
if test.Foo != 5 {
|
|
t.Errorf("expected Foo to load 5 but is %d instead", test.Foo)
|
|
}
|
|
})
|
|
|
|
t.Run("layer-skipping matches", func(t *testing.T) {
|
|
type connect struct {
|
|
Passing
|
|
Role string `tea:"save"`
|
|
Name string `tea:"save"`
|
|
ID int `tea:"save"`
|
|
}
|
|
|
|
type request struct {
|
|
Passing
|
|
Role string `tea:"match"`
|
|
Name string `tea:"match"`
|
|
ID int `tea:"load"`
|
|
}
|
|
|
|
e := mkenv(connect{
|
|
Role: "host",
|
|
ID: 1,
|
|
})
|
|
e = e.save(connect{
|
|
Role: "player",
|
|
Name: "alice",
|
|
ID: 2,
|
|
})
|
|
e = e.save(connect{
|
|
Role: "player",
|
|
Name: "bob",
|
|
ID: 3,
|
|
})
|
|
|
|
t.Logf("before bob loaded %v", e)
|
|
bob := request{Role: "player", Name: "bob"}
|
|
if err := e.load(&bob); err != nil {
|
|
t.Errorf("failed to load bob: %s", err)
|
|
} else {
|
|
if bob.ID != 3 {
|
|
t.Errorf("expected bob to have ID 3, has %d instead", bob.ID)
|
|
}
|
|
}
|
|
t.Logf("after bob loaded %v", e)
|
|
|
|
alice := request{Role: "player", Name: "alice"}
|
|
if err := e.load(&alice); err != nil {
|
|
t.Errorf("failed to load alice: %s", err)
|
|
} else {
|
|
if alice.ID != 2 {
|
|
t.Errorf("expected alice to have ID 2, has %d instead", alice.ID)
|
|
}
|
|
}
|
|
t.Logf("after alice loaded %v", e)
|
|
|
|
host := request{Role: "host"}
|
|
if err := e.load(&host); err != nil {
|
|
t.Errorf("failed to load host: %s", err)
|
|
} else {
|
|
if host.ID != 1 {
|
|
t.Errorf("expected host to have ID 1, has %d instead", host.ID)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("junk-filtering matches", func(t *testing.T) {
|
|
type connect struct {
|
|
Passing
|
|
Role string `tea:"save"`
|
|
Name string `tea:"save"`
|
|
ID int `tea:"save"`
|
|
}
|
|
|
|
type request struct {
|
|
Passing
|
|
Role string `tea:"match"`
|
|
Name string `tea:"match"`
|
|
ID int `tea:"load"`
|
|
body string
|
|
}
|
|
|
|
type junk struct {
|
|
Passing
|
|
Fart string `tea:"save"`
|
|
}
|
|
|
|
e := mkenv(connect{
|
|
Role: "host",
|
|
ID: 1,
|
|
})
|
|
e = e.save(junk{Fart: "first-junk"})
|
|
e = e.save(request{
|
|
Role: "host",
|
|
body: "one",
|
|
})
|
|
|
|
e = e.save(connect{
|
|
Role: "player",
|
|
Name: "alice",
|
|
ID: 2000000,
|
|
})
|
|
e = e.save(Pass)
|
|
e = e.save(connect{
|
|
Role: "player",
|
|
Name: "alice",
|
|
ID: 2,
|
|
})
|
|
e = e.save(Pass)
|
|
e = e.save(connect{
|
|
Role: "player",
|
|
Name: "bob",
|
|
ID: 3,
|
|
})
|
|
e = e.save(Pass)
|
|
e = e.save(request{
|
|
Role: "player",
|
|
body: "one",
|
|
})
|
|
e = e.save(junk{Fart: "second-junk"})
|
|
|
|
bob := request{Role: "player", Name: "bob"}
|
|
alice := request{Role: "player", Name: "alice"}
|
|
host := request{Role: "host"}
|
|
|
|
if err := e.load(&bob); err != nil {
|
|
t.Errorf("failed to load bob: %s", err)
|
|
} else {
|
|
if bob.ID != 3 {
|
|
t.Errorf("expected bob to have ID 3, has %d instead", bob.ID)
|
|
}
|
|
}
|
|
|
|
if err := e.load(&alice); err != nil {
|
|
t.Log(e)
|
|
t.Errorf("failed to load alice: %s", err)
|
|
} else {
|
|
if alice.ID != 2 {
|
|
t.Errorf("expected alice to have ID 2, has %d instead", alice.ID)
|
|
}
|
|
}
|
|
|
|
if err := e.load(&host); err != nil {
|
|
t.Errorf("failed to load host: %s", err)
|
|
} else {
|
|
if host.ID != 1 {
|
|
t.Errorf("expected host to have ID 1, has %d instead", host.ID)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Constructing a test node that has multiple parents:
|
|
// -----------------------------------------------------------------------------
|
|
//
|
|
// So far the hardest thing conceptually has been figuring out how we might
|
|
// construct a test that has two parents. This is entirely unhandled by
|
|
// existing test frameworks to my knowledge, but is extremely useful in
|
|
// determining that a part of our system has no effect on another part of our
|
|
// system.
|
|
//
|
|
// Here is an example of a small test graph in which B is an optional test.
|
|
//
|
|
// Logical Execution
|
|
//
|
|
// A A
|
|
// /| / \
|
|
// / | / \
|
|
// B | ----> B C
|
|
// \ | |
|
|
// \| |
|
|
// C C
|
|
//
|
|
// On the left, we have a graph representing the logical relationship between
|
|
// the tests. On the right, a graph representing the relationship between how
|
|
// the tests would be executed. The test C has two parents, which means it is
|
|
// represented twice in the execution plan, as it will be run separately for
|
|
// each parent: once following A, and once following B. The shape of this test
|
|
// can be used to confirm that test C will pass both in the event that test B
|
|
// has been run and test B has not been run. This is used to confirm that the
|
|
// portion of our system tested by B does not violate the invariants of the
|
|
// portion of our system tested by C.
|
|
//
|
|
// The execution plan for this set of tests would consiste of the following
|
|
// test chains:
|
|
//
|
|
// A -> B -> C
|
|
// A ------> C
|
|
//
|
|
// Which go test -v would output as:
|
|
//
|
|
// === RUN A
|
|
// === RUN A/B
|
|
// === RUN A/B/C
|
|
// === RUN A/C
|
|
// --- PASS: A
|
|
// --- PASS: A/B
|
|
// --- PASS: A/B/C
|
|
// --- PASS: A/C
|
|
//
|
|
// My idea for how this is solved is to rename tea.Tree to tea.Selection, since
|
|
// it would represent something different entirely. Everywhere that tea.Tree
|
|
// appears, we would use tea.Selection instead. A selection is defined as a set
|
|
// of nodes in the test graph. A selection would have a Child method as
|
|
// tea.Tree does now, and what it would do is add to every node in the
|
|
// selection the provided test as a child. We would define an additional method
|
|
// Add on the selection, which takes another selection and returns a selection
|
|
// whose selected nodes is the union of the two input selections. More simply:
|
|
// you can add selections together.
|
|
//
|
|
// We could write this as follows:
|
|
//
|
|
// 1 root := New(A)
|
|
// 2 b := root.Child(B)
|
|
// 3 both := root.And(b)
|
|
// 4 leaves := both.Child(C)
|
|
//
|
|
// line 1: root is a selection consisting of one node. That node contains
|
|
// test A.
|
|
// line 2: b is a selection consisting of one node. That node contains
|
|
// test B. The node is a child of the root node.
|
|
// line 3: both is a selection consisting of both of the nodes that
|
|
// currently exist in the graph.
|
|
// line 4: we add a new node to the graph for every node in the input
|
|
// selection. leaves is a new selection, consisting of the two added
|
|
// nodes, both of which contain the value of C, but having different
|
|
// parents.
|
|
//
|
|
// Alternatively:
|
|
//
|
|
// root := New(A)
|
|
// root.Child(B).And(root).Child(C)
|
|
//
|
|
// If we permit a selection to append multiple children, we could write this as
|
|
// follows:
|
|
//
|
|
// root := New(A)
|
|
// root.Child(B, Pass).Child(C)
|
|
//
|
|
// This last form is not strictly the same, since it includes an additional
|
|
// node in the graph which is a passing test. However since Pass is a
|
|
// specific example, we can trivially remove nodes having a test value of
|
|
// Pass in the planning phase. I'm not sure if I like this. I've tripped
|
|
// myself up thinking about it because I keep forgetting that Child does not
|
|
// make a sequence. Perhaps "Child" is no longer the right name for this
|
|
// method.
|
|
//
|
|
// Another simple example: a diamond-shaped test graph
|
|
//
|
|
// Logical Execution
|
|
//
|
|
// A A
|
|
// / \ / \
|
|
// / \ / \
|
|
// B C ----> B C
|
|
// \ / | |
|
|
// \ / | |
|
|
// D D D'
|
|
//
|
|
// Test Plan:
|
|
//
|
|
// A -> B -> D
|
|
// A -> C -> D
|
|
//
|
|
// go test -v output:
|
|
//
|
|
// === RUN A
|
|
// === RUN A/B
|
|
// === RUN A/B/D
|
|
// === RUN A/C
|
|
// === RUN A/C/D
|
|
// --- PASS: A
|
|
// --- PASS: A/B
|
|
// --- PASS: A/B/D
|
|
// --- PASS: A/C
|
|
// --- PASS: A/C/D
|
|
//
|
|
// Expressed in test code as follows:
|
|
//
|
|
// root := New(A)
|
|
// both := root.Child(B, C)
|
|
// both.Child(D)
|
|
//
|
|
// Alternatively:
|
|
//
|
|
// New(A).Child(B, C).Child(D)
|
|
//
|
|
//
|
|
//
|
|
// This API is fairly straightforward to use, but breaks down with even simple
|
|
// shapes:
|
|
//
|
|
// A
|
|
// / \
|
|
// / \
|
|
// B C
|
|
// / \ /
|
|
// / \ /
|
|
// E D
|
|
//
|
|
// Test Plan:
|
|
//
|
|
// A -> B -> E
|
|
// A -> B -> D
|
|
// A -> C -> D
|
|
//
|
|
// go test -v output:
|
|
//
|
|
// === RUN A
|
|
// === RUN A/B
|
|
// === RUN A/B/E
|
|
// === RUN A/B/D
|
|
// === RUN A/C
|
|
// === RUN A/C/D
|
|
// --- PASS: A
|
|
// --- PASS: A/B
|
|
// --- PASS: A/B/E
|
|
// --- PASS: A/B/D
|
|
// --- PASS: A/C
|
|
// --- PASS: A/C/D
|
|
//
|
|
// Expressed as:
|
|
//
|
|
// root := New(A)
|
|
// b := root.Child(B)
|
|
// c := root.Child(C)
|
|
// b.Child(E)
|
|
// b.And(c).Child(D)
|
|
//
|
|
// The ergonomics with this case are quite poor. I'm not sure how to improve
|
|
// them.
|