Files
gastown/internal/formula/parser_test.go
rictus a395b4e19b feat(formula): Add formula.toml parser and validation
Implement internal/formula package for parsing and validating formula.toml files:
- types.go: Formula, Step, Leg, Aspect, Template, Input, Var structs
- parser.go: TOML parsing using BurntSushi/toml, validation, dependency resolution
- Supports convoy, workflow, expansion, and aspect formula types
- Infers type from content when not explicitly set
- Validates required fields, unique IDs, and dependency references
- Detects circular dependencies in workflow steps
- Provides TopologicalSort and ReadySteps for execution planning

(gt-5chbk)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:01:23 -08:00

356 lines
6.6 KiB
Go

package formula
import (
"testing"
)
func TestParse_Workflow(t *testing.T) {
data := []byte(`
description = "Test workflow"
formula = "test-workflow"
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "First Step"
description = "Do the first thing"
[[steps]]
id = "step2"
title = "Second Step"
description = "Do the second thing"
needs = ["step1"]
[[steps]]
id = "step3"
title = "Third Step"
description = "Do the third thing"
needs = ["step2"]
[vars]
[vars.feature]
description = "The feature to implement"
required = true
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if f.Name != "test-workflow" {
t.Errorf("Name = %q, want %q", f.Name, "test-workflow")
}
if f.Type != TypeWorkflow {
t.Errorf("Type = %q, want %q", f.Type, TypeWorkflow)
}
if len(f.Steps) != 3 {
t.Errorf("len(Steps) = %d, want 3", len(f.Steps))
}
if f.Steps[1].Needs[0] != "step1" {
t.Errorf("step2.Needs[0] = %q, want %q", f.Steps[1].Needs[0], "step1")
}
}
func TestParse_Convoy(t *testing.T) {
data := []byte(`
description = "Test convoy"
formula = "test-convoy"
type = "convoy"
version = 1
[[legs]]
id = "leg1"
title = "Leg One"
focus = "Focus area 1"
description = "First leg"
[[legs]]
id = "leg2"
title = "Leg Two"
focus = "Focus area 2"
description = "Second leg"
[synthesis]
title = "Synthesis"
description = "Combine results"
depends_on = ["leg1", "leg2"]
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if f.Name != "test-convoy" {
t.Errorf("Name = %q, want %q", f.Name, "test-convoy")
}
if f.Type != TypeConvoy {
t.Errorf("Type = %q, want %q", f.Type, TypeConvoy)
}
if len(f.Legs) != 2 {
t.Errorf("len(Legs) = %d, want 2", len(f.Legs))
}
if f.Synthesis == nil {
t.Fatal("Synthesis is nil")
}
if len(f.Synthesis.DependsOn) != 2 {
t.Errorf("len(Synthesis.DependsOn) = %d, want 2", len(f.Synthesis.DependsOn))
}
}
func TestParse_Expansion(t *testing.T) {
data := []byte(`
description = "Test expansion"
formula = "test-expansion"
type = "expansion"
version = 1
[[template]]
id = "{target}.draft"
title = "Draft: {target.title}"
description = "Initial draft"
[[template]]
id = "{target}.refine"
title = "Refine"
description = "Refine the draft"
needs = ["{target}.draft"]
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if f.Name != "test-expansion" {
t.Errorf("Name = %q, want %q", f.Name, "test-expansion")
}
if f.Type != TypeExpansion {
t.Errorf("Type = %q, want %q", f.Type, TypeExpansion)
}
if len(f.Template) != 2 {
t.Errorf("len(Template) = %d, want 2", len(f.Template))
}
}
func TestValidate_MissingName(t *testing.T) {
data := []byte(`
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "Step"
`)
_, err := Parse(data)
if err == nil {
t.Error("expected error for missing formula name")
}
}
func TestValidate_InvalidType(t *testing.T) {
data := []byte(`
formula = "test"
type = "invalid"
version = 1
[[steps]]
id = "step1"
`)
_, err := Parse(data)
if err == nil {
t.Error("expected error for invalid type")
}
}
func TestValidate_DuplicateStepID(t *testing.T) {
data := []byte(`
formula = "test"
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "Step 1"
[[steps]]
id = "step1"
title = "Step 1 duplicate"
`)
_, err := Parse(data)
if err == nil {
t.Error("expected error for duplicate step id")
}
}
func TestValidate_UnknownDependency(t *testing.T) {
data := []byte(`
formula = "test"
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "Step 1"
needs = ["nonexistent"]
`)
_, err := Parse(data)
if err == nil {
t.Error("expected error for unknown dependency")
}
}
func TestValidate_Cycle(t *testing.T) {
data := []byte(`
formula = "test"
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "Step 1"
needs = ["step2"]
[[steps]]
id = "step2"
title = "Step 2"
needs = ["step1"]
`)
_, err := Parse(data)
if err == nil {
t.Error("expected error for cycle")
}
}
func TestTopologicalSort(t *testing.T) {
data := []byte(`
formula = "test"
type = "workflow"
version = 1
[[steps]]
id = "step3"
title = "Step 3"
needs = ["step2"]
[[steps]]
id = "step1"
title = "Step 1"
[[steps]]
id = "step2"
title = "Step 2"
needs = ["step1"]
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
order, err := f.TopologicalSort()
if err != nil {
t.Fatalf("TopologicalSort failed: %v", err)
}
// step1 must come before step2, step2 must come before step3
indexOf := func(id string) int {
for i, x := range order {
if x == id {
return i
}
}
return -1
}
if indexOf("step1") > indexOf("step2") {
t.Error("step1 should come before step2")
}
if indexOf("step2") > indexOf("step3") {
t.Error("step2 should come before step3")
}
}
func TestReadySteps(t *testing.T) {
data := []byte(`
formula = "test"
type = "workflow"
version = 1
[[steps]]
id = "step1"
title = "Step 1"
[[steps]]
id = "step2"
title = "Step 2"
needs = ["step1"]
[[steps]]
id = "step3"
title = "Step 3"
needs = ["step1"]
[[steps]]
id = "step4"
title = "Step 4"
needs = ["step2", "step3"]
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Initially only step1 is ready
ready := f.ReadySteps(map[string]bool{})
if len(ready) != 1 || ready[0] != "step1" {
t.Errorf("ReadySteps({}) = %v, want [step1]", ready)
}
// After completing step1, step2 and step3 are ready
ready = f.ReadySteps(map[string]bool{"step1": true})
if len(ready) != 2 {
t.Errorf("ReadySteps({step1}) = %v, want [step2, step3]", ready)
}
// After completing step1, step2, step3 is still ready
ready = f.ReadySteps(map[string]bool{"step1": true, "step2": true})
if len(ready) != 1 || ready[0] != "step3" {
t.Errorf("ReadySteps({step1, step2}) = %v, want [step3]", ready)
}
// After completing step1, step2, step3, only step4 is ready
ready = f.ReadySteps(map[string]bool{"step1": true, "step2": true, "step3": true})
if len(ready) != 1 || ready[0] != "step4" {
t.Errorf("ReadySteps({step1, step2, step3}) = %v, want [step4]", ready)
}
}
func TestConvoyReadySteps(t *testing.T) {
data := []byte(`
formula = "test"
type = "convoy"
version = 1
[[legs]]
id = "leg1"
title = "Leg 1"
[[legs]]
id = "leg2"
title = "Leg 2"
[[legs]]
id = "leg3"
title = "Leg 3"
`)
f, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// All legs are ready initially (parallel)
ready := f.ReadySteps(map[string]bool{})
if len(ready) != 3 {
t.Errorf("ReadySteps({}) = %v, want 3 legs", ready)
}
// After completing leg1, leg2 and leg3 still ready
ready = f.ReadySteps(map[string]bool{"leg1": true})
if len(ready) != 2 {
t.Errorf("ReadySteps({leg1}) = %v, want 2 legs", ready)
}
}