Improve testing infrastructure with dynamic discovery and validation (#20)
## Summary This PR enhances the testing infrastructure with dynamic test discovery, automated validation, and improved error handling. ## Changes ### Testing Infrastructure - Added `TestSamplesValidity` integration test that validates all test files against their expected output. - Implemented dynamic test discovery using `filepath.Glob` to automatically find all `.test` files. - Renamed `benchmark_test.go` to `lambda_test.go` for better naming consistency. - Consolidated helper functions into a single `runSample` function. - Replaced all error handling with `assert` for consistent and clear test output. - Required all `.test` files to have corresponding `.expected` files. ### Iterator Improvements - Added `Swap` method to iterator for better reduction algorithm. - Improved reduction algorithm with LIFO-based iterator implementation. ### Build System - Added `make test` target to run tests without benchmarks. - Updated Makefile help text to include the new test target. ### Test Cases - Added new test cases with expected outputs: `church_5^5`, `church_6^6`, `fast_list_2^30`, `list_2^30`. - Added validation files for all test cases. ## Test plan - Run tests with expected output validation. - Run benchmarks to ensure performance is maintained. - Verify make targets work correctly. Reviewed-on: #20 Co-authored-by: M.V. Hutz <git@maximhutz.me> Co-committed-by: M.V. Hutz <git@maximhutz.me>
This commit was merged in pull request #20.
This commit is contained in:
6
Makefile
6
Makefile
@@ -1,7 +1,7 @@
|
|||||||
BINARY_NAME=lambda
|
BINARY_NAME=lambda
|
||||||
TEST=simple
|
TEST=simple
|
||||||
|
|
||||||
.PHONY: help build run profile explain graph docs bench clean
|
.PHONY: help build run profile explain graph docs test bench clean
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.SILENT:
|
.SILENT:
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ help:
|
|||||||
echo " explain - Build and run with explanation mode and profiling"
|
echo " explain - Build and run with explanation mode and profiling"
|
||||||
echo " graph - Generate and open CPU profile visualization"
|
echo " graph - Generate and open CPU profile visualization"
|
||||||
echo " docs - Start local godoc server on port 6060"
|
echo " docs - Start local godoc server on port 6060"
|
||||||
|
echo " test - Run tests for all samples"
|
||||||
echo " bench - Run benchmarks for all samples"
|
echo " bench - Run benchmarks for all samples"
|
||||||
echo " clean - Remove all build artifacts"
|
echo " clean - Remove all build artifacts"
|
||||||
|
|
||||||
@@ -38,6 +39,9 @@ docs:
|
|||||||
echo ">>> View at 'http://localhost:6060/pkg/git.maximhutz.com/max/lambda/'"
|
echo ">>> View at 'http://localhost:6060/pkg/git.maximhutz.com/max/lambda/'"
|
||||||
go run golang.org/x/tools/cmd/godoc@latest -http=:6060
|
go run golang.org/x/tools/cmd/godoc@latest -http=:6060
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -v ./cmd/lambda
|
||||||
|
|
||||||
bench:
|
bench:
|
||||||
go test -bench=. -benchtime=10x -cpu=4 ./cmd/lambda
|
go test -bench=. -benchtime=10x -cpu=4 ./cmd/lambda
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.maximhutz.com/max/lambda/internal/config"
|
|
||||||
"git.maximhutz.com/max/lambda/internal/engine"
|
|
||||||
"git.maximhutz.com/max/lambda/pkg/convert"
|
|
||||||
"git.maximhutz.com/max/lambda/pkg/lambda"
|
|
||||||
"git.maximhutz.com/max/lambda/pkg/saccharine"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Helper function to run a single sample through the lambda interpreter.
|
|
||||||
func runSample(samplePath string) error {
|
|
||||||
// Read the sample file.
|
|
||||||
input, err := os.ReadFile(samplePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse code into syntax tree.
|
|
||||||
ast, err := saccharine.Parse(string(input))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compile expression to lambda calculus.
|
|
||||||
compiled := convert.SaccharineToLambda(ast)
|
|
||||||
|
|
||||||
// Create minimal config for benchmarking.
|
|
||||||
cfg := &config.Config{
|
|
||||||
Source: config.StringSource{Data: ""},
|
|
||||||
Destination: config.StdoutDestination{},
|
|
||||||
Profile: "",
|
|
||||||
Explanation: false,
|
|
||||||
Statistics: false,
|
|
||||||
Verbose: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and run the engine.
|
|
||||||
process := engine.New(cfg, &compiled)
|
|
||||||
process.Run()
|
|
||||||
|
|
||||||
// Get final result (to ensure it's not optimized away).
|
|
||||||
_ = lambda.Stringify(compiled)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Benchmark all samples using sub-benchmarks.
|
|
||||||
func BenchmarkSamples(b *testing.B) {
|
|
||||||
samples := map[string]string{
|
|
||||||
"Church": "../../tests/church.test",
|
|
||||||
"Fast": "../../tests/fast.test",
|
|
||||||
"Saccharine": "../../tests/saccharine.test",
|
|
||||||
"Simple": "../../tests/simple.test",
|
|
||||||
"Thunk": "../../tests/thunk.test",
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, path := range samples {
|
|
||||||
b.Run(name, func(b *testing.B) {
|
|
||||||
for b.Loop() {
|
|
||||||
if err := runSample(path); err != nil {
|
|
||||||
b.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
97
cmd/lambda/lambda_test.go
Normal file
97
cmd/lambda/lambda_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.maximhutz.com/max/lambda/internal/config"
|
||||||
|
"git.maximhutz.com/max/lambda/internal/engine"
|
||||||
|
"git.maximhutz.com/max/lambda/pkg/convert"
|
||||||
|
"git.maximhutz.com/max/lambda/pkg/lambda"
|
||||||
|
"git.maximhutz.com/max/lambda/pkg/saccharine"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper function to run a single sample through the lambda interpreter.
|
||||||
|
func runSample(samplePath string) (string, error) {
|
||||||
|
// Read the sample file.
|
||||||
|
input, err := os.ReadFile(samplePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse code into syntax tree.
|
||||||
|
ast, err := saccharine.Parse(string(input))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile expression to lambda calculus.
|
||||||
|
compiled := convert.SaccharineToLambda(ast)
|
||||||
|
|
||||||
|
// Create minimal config for benchmarking.
|
||||||
|
cfg := &config.Config{
|
||||||
|
Source: config.StringSource{Data: ""},
|
||||||
|
Destination: config.StdoutDestination{},
|
||||||
|
Profile: "",
|
||||||
|
Explanation: false,
|
||||||
|
Statistics: false,
|
||||||
|
Verbose: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and run the engine.
|
||||||
|
process := engine.New(cfg, &compiled)
|
||||||
|
process.Run()
|
||||||
|
|
||||||
|
return lambda.Stringify(compiled) + "\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that all samples produce expected output.
|
||||||
|
func TestSamplesValidity(t *testing.T) {
|
||||||
|
// Discover all .test files in the tests directory.
|
||||||
|
testFiles, err := filepath.Glob("../../tests/*.test")
|
||||||
|
assert.NoError(t, err, "Failed to read tests directory.")
|
||||||
|
assert.NotEmpty(t, testFiles, "No '*.test' files found in directory.")
|
||||||
|
|
||||||
|
for _, testPath := range testFiles {
|
||||||
|
// Build expected file path.
|
||||||
|
expectedPath := strings.TrimSuffix(testPath, filepath.Ext(testPath)) + ".expected"
|
||||||
|
|
||||||
|
name := strings.TrimSuffix(filepath.Base(testPath), filepath.Ext(testPath))
|
||||||
|
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
// Run the sample and capture output.
|
||||||
|
actual, err := runSample(testPath)
|
||||||
|
assert.NoError(t, err, "Failed to run sample.")
|
||||||
|
|
||||||
|
// Read expected output.
|
||||||
|
expectedBytes, err := os.ReadFile(expectedPath)
|
||||||
|
assert.NoError(t, err, "Failed to read expected output.")
|
||||||
|
expected := string(expectedBytes)
|
||||||
|
|
||||||
|
// Compare outputs.
|
||||||
|
assert.Equal(t, expected, actual, "Output does not match expected.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark all samples using sub-benchmarks.
|
||||||
|
func BenchmarkSamples(b *testing.B) {
|
||||||
|
// Discover all .test files in the tests directory.
|
||||||
|
testFiles, err := filepath.Glob("../../tests/*.test")
|
||||||
|
assert.NoError(b, err, "Failed to read tests directory.")
|
||||||
|
assert.NotEmpty(b, testFiles, "No '*.test' files found in directory.")
|
||||||
|
|
||||||
|
for _, path := range testFiles {
|
||||||
|
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||||
|
|
||||||
|
b.Run(name, func(b *testing.B) {
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := runSample(path)
|
||||||
|
assert.NoError(b, err, "Failed to run sample.")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
8
go.mod
8
go.mod
@@ -1,3 +1,11 @@
|
|||||||
module git.maximhutz.com/max/lambda
|
module git.maximhutz.com/max/lambda
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
|
require github.com/stretchr/testify v1.11.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|||||||
9
go.sum
Normal file
9
go.sum
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -5,13 +5,15 @@ type Iterator struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewIterator(expr *Expression) *Iterator {
|
func NewIterator(expr *Expression) *Iterator {
|
||||||
return &Iterator{
|
return &Iterator{[]*Expression{expr}}
|
||||||
trace: []*Expression{expr},
|
}
|
||||||
}
|
|
||||||
|
func (i *Iterator) Done() bool {
|
||||||
|
return len(i.trace) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Iterator) Current() *Expression {
|
func (i *Iterator) Current() *Expression {
|
||||||
if len(i.trace) < 1 {
|
if i.Done() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +28,22 @@ func (i *Iterator) Parent() *Expression {
|
|||||||
return i.trace[len(i.trace)-2]
|
return i.trace[len(i.trace)-2]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *Iterator) Swap(with Expression) {
|
||||||
|
current := i.Current()
|
||||||
|
if current != nil {
|
||||||
|
*current = with
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Iterator) Back() bool {
|
||||||
|
if i.Done() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
i.trace = i.trace[:len(i.trace)-1]
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (i *Iterator) Next() {
|
func (i *Iterator) Next() {
|
||||||
switch typed := (*i.Current()).(type) {
|
switch typed := (*i.Current()).(type) {
|
||||||
case *Abstraction:
|
case *Abstraction:
|
||||||
@@ -48,12 +66,3 @@ func (i *Iterator) Next() {
|
|||||||
i.trace = []*Expression{}
|
i.trace = []*Expression{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Iterator) Back() bool {
|
|
||||||
if len(i.trace) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
i.trace = i.trace[:len(i.trace)-1]
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,32 +1,5 @@
|
|||||||
package lambda
|
package lambda
|
||||||
|
|
||||||
import (
|
|
||||||
"git.maximhutz.com/max/lambda/pkg/lifo"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ReduceOnce(e *Expression) bool {
|
|
||||||
stack := lifo.New(e)
|
|
||||||
|
|
||||||
for !stack.Empty() {
|
|
||||||
top := stack.MustPop()
|
|
||||||
|
|
||||||
switch typed := (*top).(type) {
|
|
||||||
case *Abstraction:
|
|
||||||
stack.Push(&typed.body)
|
|
||||||
case *Application:
|
|
||||||
if fn, fnOk := typed.abstraction.(*Abstraction); fnOk {
|
|
||||||
*top = Substitute(fn.body, fn.parameter, typed.argument)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
stack.Push(&typed.argument)
|
|
||||||
stack.Push(&typed.abstraction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsViable(e *Expression) (*Abstraction, Expression, bool) {
|
func IsViable(e *Expression) (*Abstraction, Expression, bool) {
|
||||||
if e == nil {
|
if e == nil {
|
||||||
return nil, nil, false
|
return nil, nil, false
|
||||||
@@ -42,18 +15,15 @@ func IsViable(e *Expression) (*Abstraction, Expression, bool) {
|
|||||||
func ReduceAll(e *Expression, step func()) {
|
func ReduceAll(e *Expression, step func()) {
|
||||||
it := NewIterator(e)
|
it := NewIterator(e)
|
||||||
|
|
||||||
for it.Current() != nil {
|
for !it.Done() {
|
||||||
current := it.Current()
|
if fn, arg, ok := IsViable(it.Current()); !ok {
|
||||||
|
|
||||||
if fn, arg, ok := IsViable(current); !ok {
|
|
||||||
it.Next()
|
it.Next()
|
||||||
} else {
|
} else {
|
||||||
*current = Substitute(fn.body, fn.parameter, arg)
|
it.Swap(Substitute(fn.body, fn.parameter, arg))
|
||||||
step()
|
step()
|
||||||
|
|
||||||
if _, _, ok := IsViable(it.Parent()); ok {
|
if _, _, ok := IsViable(it.Parent()); ok {
|
||||||
it.Back()
|
it.Back()
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package lifo
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
type LIFO[T any] []T
|
|
||||||
|
|
||||||
func New[T any](items ...T) *LIFO[T] {
|
|
||||||
l := LIFO[T](items)
|
|
||||||
return &l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *LIFO[T]) Push(item T) {
|
|
||||||
*l = append(*l, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *LIFO[T]) Empty() bool {
|
|
||||||
return len(*l) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *LIFO[T]) MustPop() T {
|
|
||||||
var item T
|
|
||||||
|
|
||||||
*l, item = (*l)[:len(*l)-1], (*l)[len(*l)-1]
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *LIFO[T]) Pop() (T, error) {
|
|
||||||
var item T
|
|
||||||
|
|
||||||
if l.Empty() {
|
|
||||||
return item, fmt.Errorf("stack is exhausted")
|
|
||||||
}
|
|
||||||
|
|
||||||
return l.MustPop(), nil
|
|
||||||
}
|
|
||||||
1
tests/church_5^5.expected
Normal file
1
tests/church_5^5.expected
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,8 @@
|
|||||||
0 := \f.\x.x
|
0 := \f.\x.x
|
||||||
inc n := \f x.(f (n f x))
|
inc n := \f x.(f (n f x))
|
||||||
exp n m := (m n)
|
exp n m := (m n)
|
||||||
|
print n := (n F X)
|
||||||
|
|
||||||
N := (inc (inc (inc (inc (inc 0)))))
|
N := (inc (inc (inc (inc (inc 0)))))
|
||||||
|
|
||||||
(exp N N)
|
(print (exp N N))
|
||||||
1
tests/church_6^6.expected
Normal file
1
tests/church_6^6.expected
Normal file
File diff suppressed because one or more lines are too long
8
tests/church_6^6.test
Normal file
8
tests/church_6^6.test
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
0 := \f.\x.x
|
||||||
|
inc n := \f x.(f (n f x))
|
||||||
|
exp n m := (m n)
|
||||||
|
print n := (n F X)
|
||||||
|
|
||||||
|
N := (inc (inc (inc (inc (inc (inc 0))))))
|
||||||
|
|
||||||
|
(print (exp N N))
|
||||||
1
tests/fast_list_2^30.expected
Normal file
1
tests/fast_list_2^30.expected
Normal file
@@ -0,0 +1 @@
|
|||||||
|
(0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (1 END)))))))))))))))))))))))))))))))
|
||||||
1
tests/list_2^30.expected
Normal file
1
tests/list_2^30.expected
Normal file
@@ -0,0 +1 @@
|
|||||||
|
(0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (0 (1 END)))))))))))))))))))))))))))))))
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
(\0.
|
|
||||||
(\inc.
|
|
||||||
(\add.
|
|
||||||
(\mult.
|
|
||||||
(\exp.
|
|
||||||
(exp (inc (inc (inc (inc 0)))) (inc (inc (inc (inc (inc 0))))))
|
|
||||||
\n m.(m n)
|
|
||||||
)
|
|
||||||
\m n f.(m (n f))
|
|
||||||
)
|
|
||||||
\n m.(m inc n)
|
|
||||||
)
|
|
||||||
\n f x.(f (n f x))
|
|
||||||
)
|
|
||||||
\f x.x
|
|
||||||
)
|
|
||||||
1
tests/thunk.expected
Normal file
1
tests/thunk.expected
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VALUE
|
||||||
Reference in New Issue
Block a user