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>
356 lines
6.6 KiB
Go
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)
|
|
}
|
|
}
|