testmark-go

Add recursion flexibility

commit 4e855bce0a6121dc20bb2843656f1f8492272904 · parent 095106dc5d · browse this tree
Calvin Behling, 2022-11-13 18:52:34 UTC

- Adds tests to output that will show up as skipped.
This should make the behavior of testexec easier understand and debug
using go test -v.
- Adds a RecursionFn to testexec to give users the ability to control recursion.
- Adds a Path variable to DirEnt structs which contains its full path.

Overall default behavior is unchanged.
diff --git a/go.mod b/go.mod
index dc56ee0..071e0b4 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,5 @@
 module github.com/warpfork/go-testmark
 
 go 1.16
+
+require github.com/frankban/quicktest v1.14.3 // indirect
diff --git a/index.go b/index.go
index 6d3463d..5783e43 100644
--- a/index.go
+++ b/index.go
@@ -16,27 +16,28 @@ import (
 func (doc *Document) BuildDirIndex() {
 	doc.DirEnt = &DirEnt{}
 	for _, hunk := range doc.DataHunks {
-		doc.DirEnt.fill(strings.Split(hunk.Name, "/"), hunk.Hunk)
+		doc.DirEnt.fill(strings.Split(hunk.Name, "/"), 0, hunk.Hunk)
 	}
 }
 
-func (dirent *DirEnt) fill(pathSegs []string, hunk Hunk) {
-	if len(pathSegs) == 0 {
+func (dirent *DirEnt) fill(pathSegs []string, pathIdx int, hunk Hunk) {
+	if pathIdx >= len(pathSegs) {
 		dirent.Hunk = &hunk
 		return
 	}
 	if dirent.Children == nil {
 		dirent.Children = make(map[string]*DirEnt)
 	}
-	if next, exists := dirent.Children[pathSegs[0]]; exists {
-		next.fill(pathSegs[1:], hunk)
-	} else {
-		l := len(dirent.ChildrenList)
-		child := DirEnt{
-			Name: pathSegs[0],
-		}
-		dirent.ChildrenList = append(dirent.ChildrenList, &child)
-		dirent.Children[pathSegs[0]] = &child
-		dirent.ChildrenList[l].fill(pathSegs[1:], hunk)
+	if next, exists := dirent.Children[pathSegs[pathIdx]]; exists {
+		next.fill(pathSegs, pathIdx+1, hunk)
+		return
 	}
+
+	l := len(dirent.ChildrenList)
+	dirent.ChildrenList = append(dirent.ChildrenList, DirEnt{
+		Name: pathSegs[pathIdx],
+		Path: strings.Join(pathSegs[:pathIdx+1], "/"),
+	})
+	dirent.Children[pathSegs[pathIdx]] = &dirent.ChildrenList[l]
+	dirent.ChildrenList[l].fill(pathSegs, pathIdx+1, hunk)
 }
diff --git a/testexec/defaults.go b/testexec/defaults.go
index 3ecad65..2403593 100644
--- a/testexec/defaults.go
+++ b/testexec/defaults.go
@@ -1,10 +1,14 @@
 package testexec
 
 import (
+	"fmt"
 	"io"
 	"os/exec"
+	"strings"
 	"syscall"
 	"testing"
+
+	"github.com/warpfork/go-testmark"
 )
 
 func ExecFn_Exec(args []string, stdin io.Reader, stdout, stderr io.Writer) (exitcode int, oshit error) {
@@ -46,3 +50,28 @@ func defaultAssertFn(t *testing.T, actual, expect string) {
 		t.Errorf("expected: %q; actual: %q", expect, actual)
 	}
 }
+
+// Hash Table of all the "special" nodes used by testmark.
+var DefaultLeafNodes = map[string]struct{}{
+	"exitcode": {},
+	"stderr":   {},
+	"stdout":   {},
+	"output":   {},
+	"input":    {},
+	"sequence": {},
+	"script":   {},
+	"fs":       {},
+}
+
+func RecursionFn_Then(t *testing.T, dir testmark.DirEnt) error {
+	t.Helper()
+	if _, exists := DefaultLeafNodes[dir.Name]; exists {
+		t.Logf("will not recurse into special leaf node %q", dir.Name)
+		return IgnoreRecursion
+	}
+	if len(dir.Name) > 5 && strings.HasPrefix(dir.Name, "then-") {
+		return nil
+	}
+	t.Logf("skipping dir recursion: %q does not begin with %q ", dir.Name, "then-")
+	return fmt.Errorf("%q does not begin with %q: %w", dir.Name, "then-", SkipRecursion)
+}
diff --git a/testexec/selfexercise.md b/testexec/selfexercise.md
index 67d4842..1040c6c 100644
--- a/testexec/selfexercise.md
+++ b/testexec/selfexercise.md
@@ -99,3 +99,23 @@ cat - | sed 's/ is/ was/'  | sed s/will/should/
 ```
 this was stdin and should be echoed
 ```
+
+---
+
+The default recursion function won't reach everything if you don't formulate your paths correctly.
+
+[testmark]:# (bad/script)
+```
+  # no-op
+```
+
+[testmark]:# (bad/then-missing-script/then-another-thing/script)
+```
+  # no-op, this will not be reached because it missing executable blocks in parent steps
+```
+
+[testmark]:# (bad/not-a-then-statement/script)
+```
+  # no-op, this script will not run because the subdirectory does not begin with "then-"
+```
+
diff --git a/testexec/testexec.go b/testexec/testexec.go
index 8006d83..9c2e30d 100644
--- a/testexec/testexec.go
+++ b/testexec/testexec.go
@@ -10,6 +10,7 @@ package testexec
 
 import (
 	"bytes"
+	"errors"
 	"io"
 	"io/ioutil"
 	"os"
@@ -50,6 +51,18 @@ type FilterFn func(line string) (replacement string)
 // The default behavior, if a Tester object doesn't get an AssertFn, is to fall back to a very basic call to `t.Errorf`.
 type AssertFn func(t *testing.T, actual, expect string)
 
+var (
+	// SkipRecursion can be returned by a RecursionFn for paths that should show up in the test as skipped
+	SkipRecursion = errors.New("skip this directory")
+	// IgnoreRecursion can be returned by a RecursionFn for paths that should not run a new test at all
+	IgnoreRecursion = errors.New("ignore this directory")
+)
+
+// RecursionFn can be used in Tester to specify a recursion pattern
+// The default recursion pattern will recurse whenever it finds "then-"
+// Returning a non-nil error will cause the test for the directory to fail unless the error is a SkipRecursion or IgnoreRecursion error
+type RecursionFn func(*testing.T, testmark.DirEnt) error
+
 // Tester is a configuration-gathering structure.
 // Each of the `Test*` methods upon it will use these callbacks to define their behavior.
 //
@@ -66,6 +79,7 @@ type Tester struct {
 	ScriptFn
 	FilterFn
 	AssertFn
+	RecursionFn
 
 	Patches *testmark.PatchAccumulator
 }
@@ -161,11 +175,10 @@ func (tcfg Tester) test(t *testing.T, data *testmark.DirEnt, allowExec, allowScr
 	sequenceHunk, sequenceMode := data.Children["sequence"]
 	scriptHunk, scriptMode := data.Children["script"]
 	if !sequenceMode && !scriptMode {
-		return
+		t.Skipf("dir %q does not contain a 'script' or 'sequence' hunk and will be skipped", data.Name)
 	}
 	if sequenceMode && scriptMode {
-		t.Logf("warning: dir %q contained both a 'script' and a 'sequence' hunk, which is nonsensical", data.Name)
-		t.SkipNow()
+		t.Skipf("warning: dir %q contained both a 'script' and a 'sequence' hunk, which is nonsensical", data.Name)
 	}
 	if sequenceMode && !allowExec {
 		t.Skipf("found sequence hunk but the test framework was invoked without permission to run those")
@@ -299,25 +312,37 @@ func (tcfg Tester) test(t *testing.T, data *testmark.DirEnt, allowExec, allowScr
 		}
 	})
 
-	// Look for "then-*" dirs.
-	//  If we're already failed -- make the run block, but skip it.
-	//  If we're going to procede: make a new tempdir, copy the contents, and then procede by recursing.
+	tcfg.recurse(t, data, allowExec, allowScript, dir)
+}
+
+func (tcfg Tester) recurse(t *testing.T, data testmark.DirEnt, allowExec bool, allowScript bool, parentTmpDir string) {
+	if tcfg.RecursionFn == nil {
+		tcfg.RecursionFn = RecursionFn_Then
+	}
 	for _, child := range data.ChildrenList {
-		if len(child.Name) > 5 && strings.HasPrefix(child.Name, "then-") {
-			alreadyFailed := t.Failed()
-			t.Run(child.Name, func(t *testing.T) {
-				if alreadyFailed {
-					t.Skipf("parent commands failed, so while more commands are specified, testing them is not meaningful")
-				}
-				tcfg.test(t, child, allowExec, allowScript, dir)
-			})
+		recurseErr := tcfg.RecursionFn(t, child)
+		if errors.Is(recurseErr, IgnoreRecursion) {
+			continue
 		}
+		alreadyFailed := t.Failed()
+		t.Run(child.Name, func(t *testing.T) {
+			if alreadyFailed {
+				t.Skipf("parent commands failed, so while more commands are specified, testing them is not meaningful")
+			}
+			if errors.Is(recurseErr, SkipRecursion) {
+				t.Skip(recurseErr)
+			}
+			if recurseErr != nil {
+				t.Fatal(recurseErr)
+			}
+			tcfg.test(t, child, allowExec, allowScript, parentTmpDir)
+		})
 	}
-
 }
 
 func (tcfg Tester) doSequence(t *testing.T, hunk *testmark.Hunk, stdin io.Reader, stdout, stderr io.Writer) (exitcode int) {
 	t.Helper()
+	t.Logf("running sequence: %q", hunk.Name)
 	// Loop over the lines in the sequence.
 	lines := bytes.Split(hunk.Body, []byte{'\n'})
 	for _, line := range lines {
@@ -340,6 +365,7 @@ func (tcfg Tester) doSequence(t *testing.T, hunk *testmark.Hunk, stdin io.Reader
 
 func (tcfg Tester) doScript(t *testing.T, hunk *testmark.Hunk, stdin io.Reader, stdout, stderr io.Writer) (exitcode int) {
 	t.Helper()
+	t.Logf("running script: %q", hunk.Name)
 	var err error
 	exitcode, err = tcfg.ScriptFn(string(hunk.Body), stdin, stdout, stderr)
 	if err != nil {
diff --git a/testexec/testexec_test.go b/testexec/testexec_test.go
index 0701f4a..ae1ed70 100644
--- a/testexec/testexec_test.go
+++ b/testexec/testexec_test.go
@@ -1,13 +1,16 @@
 package testexec_test
 
 import (
+	"sort"
 	"testing"
 
 	"github.com/warpfork/go-testmark"
 	"github.com/warpfork/go-testmark/testexec"
+
+	qt "github.com/frankban/quicktest"
 )
 
-func Test(t *testing.T) {
+func TestSelf(t *testing.T) {
 	filename := "selfexercise.md"
 	doc, err := testmark.ReadFile(filename)
 	if err != nil {
@@ -26,3 +29,126 @@ func Test(t *testing.T) {
 	}
 	patches.WriteFileWithPatches(doc, filename)
 }
+
+// TestRecursion tests edge cases around which nodes are traversed via recursion
+func TestRecursion(t *testing.T) {
+	rtested := map[string]int{}
+	rcalled := map[string]int{}
+	sum := func(m map[string]int) int {
+		result := 0
+		for _, v := range m {
+			result += v
+		}
+		return result
+	}
+
+	rfn := func(t *testing.T, dir testmark.DirEnt) error {
+		rtested[dir.Path]++
+		result := testexec.RecursionFn_Then(t, dir)
+		if result == nil {
+			rcalled[dir.Path]++
+		}
+		return result
+	}
+
+	filename := "selfexercise.md"
+	doc, err := testmark.ReadFile(filename)
+	if err != nil {
+		t.Fatalf("spec file parse failed?!: %s", err)
+	}
+
+	doc.BuildDirIndex()
+
+	patches := testmark.PatchAccumulator{}
+	for _, dir := range doc.DirEnt.ChildrenList {
+		rtested[dir.Path]++
+		t.Run(dir.Name, func(t *testing.T) {
+			rcalled[dir.Path]++
+			test := testexec.Tester{
+				Patches:     &patches,
+				RecursionFn: rfn,
+			}
+			test.Test(t, dir)
+		})
+	}
+	patches.WriteFileWithPatches(doc, filename)
+
+	pathList := collectPaths(*doc.DirEnt)
+	sort.Strings(pathList)
+	t.Log("[idx]: [test count]-[recursion count]: [path]")
+	for idx, path := range pathList {
+		check := rtested[path]
+		recurse := rcalled[path]
+		t.Logf("%02d: %d-%d: %s", idx, check, recurse, path)
+	}
+
+	qt.Assert(t, rtested[""], qt.Equals, 0)
+	qt.Assert(t, rcalled[""], qt.Equals, 0)
+	qt.Assert(t, rtested["bad"], qt.Equals, 1)
+	qt.Assert(t, rcalled["bad"], qt.Equals, 1)
+	qt.Assert(t, rtested["bad/script"], qt.Equals, 1)
+	qt.Assert(t, rcalled["bad/script"], qt.Equals, 0)
+	qt.Assert(t, rtested["bad/then-missing-script"], qt.Equals, 1)
+	qt.Assert(t, rcalled["bad/then-missing-script"], qt.Equals, 1)
+	qt.Assert(t, rtested["bad/then-missing-script/then-another-thing"], qt.Equals, 0)
+	qt.Assert(t, rcalled["bad/then-missing-script/then-another-thing"], qt.Equals, 0)
+	qt.Assert(t, rtested["bad/then-missing-script/then-another-thing/script"], qt.Equals, 0)
+	qt.Assert(t, rcalled["bad/then-missing-script/then-another-thing/script"], qt.Equals, 0)
+	qt.Assert(t, rtested["bad/not-a-then-statement"], qt.Equals, 1)
+	qt.Assert(t, rcalled["bad/not-a-then-statement"], qt.Equals, 0)
+	qt.Assert(t, rtested["bad/not-a-then-statement/script"], qt.Equals, 0)
+	qt.Assert(t, rcalled["bad/not-a-then-statement/script"], qt.Equals, 0)
+
+	qt.Assert(t, rtested["using-stdin"], qt.Equals, 1)
+	qt.Assert(t, rcalled["using-stdin"], qt.Equals, 1)
+	qt.Assert(t, rtested["using-stdin/input"], qt.Equals, 1)
+	qt.Assert(t, rcalled["using-stdin/input"], qt.Equals, 0)
+	qt.Assert(t, rtested["using-stdin/output"], qt.Equals, 1)
+	qt.Assert(t, rcalled["using-stdin/output"], qt.Equals, 0)
+	qt.Assert(t, rtested["using-stdin/script"], qt.Equals, 1)
+	qt.Assert(t, rcalled["using-stdin/script"], qt.Equals, 0)
+
+	qt.Assert(t, rtested["whee"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee"], qt.Equals, 1)
+	qt.Assert(t, rtested["whee/fs"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/fs"], qt.Equals, 0)
+	qt.Assert(t, rtested["whee/fs/a"], qt.Equals, 0)
+	qt.Assert(t, rcalled["whee/fs/a"], qt.Equals, 0)
+	qt.Assert(t, rtested["whee/output"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/output"], qt.Equals, 0)
+	qt.Assert(t, rtested["whee/script"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/script"], qt.Equals, 0)
+	qt.Assert(t, rtested["whee/then-more-files"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/then-more-files"], qt.Equals, 1)
+	qt.Assert(t, rtested["whee/then-more-files/fs"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/then-more-files/fs"], qt.Equals, 0)
+	qt.Assert(t, rtested["whee/then-more-files/fs/b"], qt.Equals, 0)
+	qt.Assert(t, rcalled["whee/then-more-files/fs/b"], qt.Equals, 0)
+	qt.Assert(t, rtested["whee/then-more-files/output"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/then-more-files/output"], qt.Equals, 0)
+	qt.Assert(t, rtested["whee/then-more-files/script"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/then-more-files/script"], qt.Equals, 0)
+	qt.Assert(t, rtested["whee/then-touching-files"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/then-touching-files"], qt.Equals, 1)
+	qt.Assert(t, rtested["whee/then-touching-files/output"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/then-touching-files/output"], qt.Equals, 0)
+	qt.Assert(t, rtested["whee/then-touching-files/script"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/then-touching-files/script"], qt.Equals, 0)
+	qt.Assert(t, rtested["whee/then-touching-files/then-subtesting-again"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/then-touching-files/then-subtesting-again"], qt.Equals, 1)
+	qt.Assert(t, rtested["whee/then-touching-files/then-subtesting-again/output"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/then-touching-files/then-subtesting-again/output"], qt.Equals, 0)
+	qt.Assert(t, rtested["whee/then-touching-files/then-subtesting-again/script"], qt.Equals, 1)
+	qt.Assert(t, rcalled["whee/then-touching-files/then-subtesting-again/script"], qt.Equals, 0)
+
+	qt.Assert(t, sum(rtested), qt.Equals, 22)
+	qt.Assert(t, sum(rcalled), qt.Equals, 7)
+}
+
+func collectPaths(d testmark.DirEnt) []string {
+	result := []string{d.Path}
+	for _, c := range d.ChildrenList {
+		result = append(result, collectPaths(c)...)
+	}
+	return result
+}
diff --git a/testmark.go b/testmark.go
index 4113279..5603c0d 100644
--- a/testmark.go
+++ b/testmark.go
@@ -67,6 +67,9 @@ type DirEnt struct {
 	// (Note that if there's a Hunk in this DirEnt, its name may be different -- it still has the *full* path name.)
 	Name string
 
+	// The fullpath
+	Path string
+
 	// A hunk, or nil.
 	Hunk *Hunk