Adds Type and Backoff fields to MoleculeStep for patrol agents to implement cost-saving await-signal patterns: - Type field: "task" (default), "wait" (await-signal), etc. - BackoffConfig: base interval, multiplier, max cap - Parsing for "Type:" and "Backoff:" lines in step definitions - Comprehensive tests for new parsing functionality Step definition format: ## Step: await-signal Type: wait Backoff: base=30s, multiplier=2, max=10m Agents interpret these declaratively, implementing backoff behavior at runtime. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
790 lines
18 KiB
Go
790 lines
18 KiB
Go
package beads
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseMoleculeSteps_EmptyDescription(t *testing.T) {
|
|
steps, err := ParseMoleculeSteps("")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 0 {
|
|
t.Errorf("expected 0 steps, got %d", len(steps))
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_NoSteps(t *testing.T) {
|
|
desc := `This is a molecule description without any steps.
|
|
Just some prose text.`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 0 {
|
|
t.Errorf("expected 0 steps, got %d", len(steps))
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_SingleStep(t *testing.T) {
|
|
desc := `## Step: implement
|
|
Write the code carefully.
|
|
Follow existing patterns.`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 1 {
|
|
t.Fatalf("expected 1 step, got %d", len(steps))
|
|
}
|
|
|
|
step := steps[0]
|
|
if step.Ref != "implement" {
|
|
t.Errorf("Ref = %q, want implement", step.Ref)
|
|
}
|
|
if step.Title != "Write the code carefully." {
|
|
t.Errorf("Title = %q, want 'Write the code carefully.'", step.Title)
|
|
}
|
|
if step.Instructions != "Write the code carefully.\nFollow existing patterns." {
|
|
t.Errorf("Instructions = %q", step.Instructions)
|
|
}
|
|
if len(step.Needs) != 0 {
|
|
t.Errorf("Needs = %v, want empty", step.Needs)
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_MultipleSteps(t *testing.T) {
|
|
desc := `This workflow takes a task through multiple stages.
|
|
|
|
## Step: design
|
|
Think about architecture and patterns.
|
|
Consider edge cases.
|
|
|
|
## Step: implement
|
|
Write the implementation.
|
|
Needs: design
|
|
|
|
## Step: test
|
|
Write comprehensive tests.
|
|
Needs: implement
|
|
|
|
## Step: submit
|
|
Submit for review.
|
|
Needs: implement, test`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 4 {
|
|
t.Fatalf("expected 4 steps, got %d", len(steps))
|
|
}
|
|
|
|
// Check design step
|
|
if steps[0].Ref != "design" {
|
|
t.Errorf("step[0].Ref = %q, want design", steps[0].Ref)
|
|
}
|
|
if len(steps[0].Needs) != 0 {
|
|
t.Errorf("step[0].Needs = %v, want empty", steps[0].Needs)
|
|
}
|
|
|
|
// Check implement step
|
|
if steps[1].Ref != "implement" {
|
|
t.Errorf("step[1].Ref = %q, want implement", steps[1].Ref)
|
|
}
|
|
if !reflect.DeepEqual(steps[1].Needs, []string{"design"}) {
|
|
t.Errorf("step[1].Needs = %v, want [design]", steps[1].Needs)
|
|
}
|
|
|
|
// Check test step
|
|
if steps[2].Ref != "test" {
|
|
t.Errorf("step[2].Ref = %q, want test", steps[2].Ref)
|
|
}
|
|
if !reflect.DeepEqual(steps[2].Needs, []string{"implement"}) {
|
|
t.Errorf("step[2].Needs = %v, want [implement]", steps[2].Needs)
|
|
}
|
|
|
|
// Check submit step with multiple dependencies
|
|
if steps[3].Ref != "submit" {
|
|
t.Errorf("step[3].Ref = %q, want submit", steps[3].Ref)
|
|
}
|
|
if !reflect.DeepEqual(steps[3].Needs, []string{"implement", "test"}) {
|
|
t.Errorf("step[3].Needs = %v, want [implement, test]", steps[3].Needs)
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_WithTier(t *testing.T) {
|
|
desc := `## Step: quick-task
|
|
Do something simple.
|
|
Tier: haiku
|
|
|
|
## Step: complex-task
|
|
Do something complex.
|
|
Needs: quick-task
|
|
Tier: opus`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 2 {
|
|
t.Fatalf("expected 2 steps, got %d", len(steps))
|
|
}
|
|
|
|
if steps[0].Tier != "haiku" {
|
|
t.Errorf("step[0].Tier = %q, want haiku", steps[0].Tier)
|
|
}
|
|
if steps[1].Tier != "opus" {
|
|
t.Errorf("step[1].Tier = %q, want opus", steps[1].Tier)
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_WithWaitsFor(t *testing.T) {
|
|
desc := `## Step: survey
|
|
Discover work items.
|
|
|
|
## Step: aggregate
|
|
Collect results from dynamically bonded children.
|
|
WaitsFor: all-children
|
|
Needs: survey
|
|
|
|
## Step: finish
|
|
Wrap up.
|
|
WaitsFor: all-children, external-signal
|
|
Needs: aggregate`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 3 {
|
|
t.Fatalf("expected 3 steps, got %d", len(steps))
|
|
}
|
|
|
|
// survey has no WaitsFor
|
|
if len(steps[0].WaitsFor) != 0 {
|
|
t.Errorf("step[0].WaitsFor = %v, want empty", steps[0].WaitsFor)
|
|
}
|
|
|
|
// aggregate waits for all-children
|
|
if !reflect.DeepEqual(steps[1].WaitsFor, []string{"all-children"}) {
|
|
t.Errorf("step[1].WaitsFor = %v, want [all-children]", steps[1].WaitsFor)
|
|
}
|
|
|
|
// finish waits for multiple conditions
|
|
if !reflect.DeepEqual(steps[2].WaitsFor, []string{"all-children", "external-signal"}) {
|
|
t.Errorf("step[2].WaitsFor = %v, want [all-children, external-signal]", steps[2].WaitsFor)
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_CaseInsensitive(t *testing.T) {
|
|
desc := `## STEP: Design
|
|
Plan the work.
|
|
NEEDS: nothing
|
|
TIER: SONNET
|
|
WAITSFOR: All-Children
|
|
|
|
## step: implement
|
|
Write code.
|
|
needs: Design
|
|
tier: Haiku
|
|
waitsfor: some-condition`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 2 {
|
|
t.Fatalf("expected 2 steps, got %d", len(steps))
|
|
}
|
|
|
|
// Note: refs preserve original case
|
|
if steps[0].Ref != "Design" {
|
|
t.Errorf("step[0].Ref = %q, want Design", steps[0].Ref)
|
|
}
|
|
if steps[0].Tier != "sonnet" {
|
|
t.Errorf("step[0].Tier = %q, want sonnet", steps[0].Tier)
|
|
}
|
|
// WaitsFor values preserve case
|
|
if !reflect.DeepEqual(steps[0].WaitsFor, []string{"All-Children"}) {
|
|
t.Errorf("step[0].WaitsFor = %v, want [All-Children]", steps[0].WaitsFor)
|
|
}
|
|
|
|
if steps[1].Ref != "implement" {
|
|
t.Errorf("step[1].Ref = %q, want implement", steps[1].Ref)
|
|
}
|
|
if steps[1].Tier != "haiku" {
|
|
t.Errorf("step[1].Tier = %q, want haiku", steps[1].Tier)
|
|
}
|
|
if !reflect.DeepEqual(steps[1].WaitsFor, []string{"some-condition"}) {
|
|
t.Errorf("step[1].WaitsFor = %v, want [some-condition]", steps[1].WaitsFor)
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_Shiny(t *testing.T) {
|
|
// The canonical example from the design doc
|
|
desc := `This workflow takes a task from design to merge.
|
|
|
|
## Step: design
|
|
Think carefully about architecture. Consider existing patterns,
|
|
trade-offs, testability.
|
|
|
|
## Step: implement
|
|
Write clean code. Follow codebase conventions.
|
|
Needs: design
|
|
|
|
## Step: review
|
|
Review for bugs, edge cases, style issues.
|
|
Needs: implement
|
|
|
|
## Step: test
|
|
Write and run tests. Cover happy path and edge cases.
|
|
Needs: implement
|
|
|
|
## Step: submit
|
|
Submit for merge via refinery.
|
|
Needs: review, test`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 5 {
|
|
t.Fatalf("expected 5 steps, got %d", len(steps))
|
|
}
|
|
|
|
expected := []struct {
|
|
ref string
|
|
needs []string
|
|
}{
|
|
{"design", nil},
|
|
{"implement", []string{"design"}},
|
|
{"review", []string{"implement"}},
|
|
{"test", []string{"implement"}},
|
|
{"submit", []string{"review", "test"}},
|
|
}
|
|
|
|
for i, exp := range expected {
|
|
if steps[i].Ref != exp.ref {
|
|
t.Errorf("step[%d].Ref = %q, want %q", i, steps[i].Ref, exp.ref)
|
|
}
|
|
if exp.needs == nil {
|
|
if len(steps[i].Needs) != 0 {
|
|
t.Errorf("step[%d].Needs = %v, want empty", i, steps[i].Needs)
|
|
}
|
|
} else if !reflect.DeepEqual(steps[i].Needs, exp.needs) {
|
|
t.Errorf("step[%d].Needs = %v, want %v", i, steps[i].Needs, exp.needs)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExpandTemplateVars(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
text string
|
|
ctx map[string]string
|
|
want string
|
|
}{
|
|
{
|
|
name: "no variables",
|
|
text: "Just plain text",
|
|
ctx: map[string]string{"foo": "bar"},
|
|
want: "Just plain text",
|
|
},
|
|
{
|
|
name: "single variable",
|
|
text: "Implement {{feature_name}} feature",
|
|
ctx: map[string]string{"feature_name": "authentication"},
|
|
want: "Implement authentication feature",
|
|
},
|
|
{
|
|
name: "multiple variables",
|
|
text: "Implement {{feature}} in {{file}}",
|
|
ctx: map[string]string{"feature": "login", "file": "auth.go"},
|
|
want: "Implement login in auth.go",
|
|
},
|
|
{
|
|
name: "unknown variable left as-is",
|
|
text: "Value is {{unknown}}",
|
|
ctx: map[string]string{"known": "value"},
|
|
want: "Value is {{unknown}}",
|
|
},
|
|
{
|
|
name: "nil context",
|
|
text: "Value is {{var}}",
|
|
ctx: nil,
|
|
want: "Value is {{var}}",
|
|
},
|
|
{
|
|
name: "empty context",
|
|
text: "Value is {{var}}",
|
|
ctx: map[string]string{},
|
|
want: "Value is {{var}}",
|
|
},
|
|
{
|
|
name: "repeated variable",
|
|
text: "{{x}} and {{x}} again",
|
|
ctx: map[string]string{"x": "foo"},
|
|
want: "foo and foo again",
|
|
},
|
|
{
|
|
name: "multiline",
|
|
text: "First line with {{a}}.\nSecond line with {{b}}.",
|
|
ctx: map[string]string{"a": "alpha", "b": "beta"},
|
|
want: "First line with alpha.\nSecond line with beta.",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ExpandTemplateVars(tt.text, tt.ctx)
|
|
if got != tt.want {
|
|
t.Errorf("ExpandTemplateVars() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_WithTemplateVars(t *testing.T) {
|
|
desc := `## Step: implement
|
|
Implement {{feature_name}} in {{target_file}}.
|
|
Follow the existing patterns.`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 1 {
|
|
t.Fatalf("expected 1 step, got %d", len(steps))
|
|
}
|
|
|
|
// Template vars should be preserved in parsed instructions
|
|
if steps[0].Instructions != "Implement {{feature_name}} in {{target_file}}.\nFollow the existing patterns." {
|
|
t.Errorf("Instructions = %q", steps[0].Instructions)
|
|
}
|
|
|
|
// Now expand them
|
|
expanded := ExpandTemplateVars(steps[0].Instructions, map[string]string{
|
|
"feature_name": "user auth",
|
|
"target_file": "auth.go",
|
|
})
|
|
|
|
if expanded != "Implement user auth in auth.go.\nFollow the existing patterns." {
|
|
t.Errorf("expanded = %q", expanded)
|
|
}
|
|
}
|
|
|
|
func TestValidateMolecule_Valid(t *testing.T) {
|
|
mol := &Issue{
|
|
ID: "mol-xyz",
|
|
Type: "molecule",
|
|
Description: `## Step: design
|
|
Plan the work.
|
|
|
|
## Step: implement
|
|
Write code.
|
|
Needs: design`,
|
|
}
|
|
|
|
err := ValidateMolecule(mol)
|
|
if err != nil {
|
|
t.Errorf("ValidateMolecule() = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateMolecule_WrongType(t *testing.T) {
|
|
mol := &Issue{
|
|
ID: "task-xyz",
|
|
Type: "task",
|
|
Description: `## Step: design\nPlan.`,
|
|
}
|
|
|
|
err := ValidateMolecule(mol)
|
|
if err == nil {
|
|
t.Error("ValidateMolecule() = nil, want error for wrong type")
|
|
}
|
|
}
|
|
|
|
func TestValidateMolecule_NoSteps(t *testing.T) {
|
|
mol := &Issue{
|
|
ID: "mol-xyz",
|
|
Type: "molecule",
|
|
Description: "Just some description without steps.",
|
|
}
|
|
|
|
err := ValidateMolecule(mol)
|
|
if err == nil {
|
|
t.Error("ValidateMolecule() = nil, want error for no steps")
|
|
}
|
|
}
|
|
|
|
func TestValidateMolecule_DuplicateRef(t *testing.T) {
|
|
mol := &Issue{
|
|
ID: "mol-xyz",
|
|
Type: "molecule",
|
|
Description: `## Step: design
|
|
Plan the work.
|
|
|
|
## Step: design
|
|
Plan again.`,
|
|
}
|
|
|
|
err := ValidateMolecule(mol)
|
|
if err == nil {
|
|
t.Error("ValidateMolecule() = nil, want error for duplicate ref")
|
|
}
|
|
}
|
|
|
|
func TestValidateMolecule_UnknownDependency(t *testing.T) {
|
|
mol := &Issue{
|
|
ID: "mol-xyz",
|
|
Type: "molecule",
|
|
Description: `## Step: implement
|
|
Write code.
|
|
Needs: nonexistent`,
|
|
}
|
|
|
|
err := ValidateMolecule(mol)
|
|
if err == nil {
|
|
t.Error("ValidateMolecule() = nil, want error for unknown dependency")
|
|
}
|
|
}
|
|
|
|
func TestValidateMolecule_SelfDependency(t *testing.T) {
|
|
mol := &Issue{
|
|
ID: "mol-xyz",
|
|
Type: "molecule",
|
|
Description: `## Step: implement
|
|
Write code.
|
|
Needs: implement`,
|
|
}
|
|
|
|
err := ValidateMolecule(mol)
|
|
if err == nil {
|
|
t.Error("ValidateMolecule() = nil, want error for self-dependency")
|
|
}
|
|
}
|
|
|
|
func TestValidateMolecule_Nil(t *testing.T) {
|
|
err := ValidateMolecule(nil)
|
|
if err == nil {
|
|
t.Error("ValidateMolecule(nil) = nil, want error")
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_WhitespaceHandling(t *testing.T) {
|
|
desc := `## Step: spaced
|
|
Indented instructions.
|
|
|
|
More indented content.
|
|
|
|
Needs: dep1 , dep2 ,dep3
|
|
Tier: opus `
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 1 {
|
|
t.Fatalf("expected 1 step, got %d", len(steps))
|
|
}
|
|
|
|
// Ref preserves original (though trimmed)
|
|
if steps[0].Ref != "spaced" {
|
|
t.Errorf("Ref = %q, want spaced", steps[0].Ref)
|
|
}
|
|
|
|
// Dependencies should be trimmed
|
|
expectedDeps := []string{"dep1", "dep2", "dep3"}
|
|
if !reflect.DeepEqual(steps[0].Needs, expectedDeps) {
|
|
t.Errorf("Needs = %v, want %v", steps[0].Needs, expectedDeps)
|
|
}
|
|
|
|
// Tier should be lowercase and trimmed
|
|
if steps[0].Tier != "opus" {
|
|
t.Errorf("Tier = %q, want opus", steps[0].Tier)
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_EmptyInstructions(t *testing.T) {
|
|
desc := `## Step: empty
|
|
|
|
## Step: next
|
|
Has content.`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 2 {
|
|
t.Fatalf("expected 2 steps, got %d", len(steps))
|
|
}
|
|
|
|
// First step has empty instructions, title defaults to ref
|
|
if steps[0].Instructions != "" {
|
|
t.Errorf("step[0].Instructions = %q, want empty", steps[0].Instructions)
|
|
}
|
|
if steps[0].Title != "empty" {
|
|
t.Errorf("step[0].Title = %q, want empty", steps[0].Title)
|
|
}
|
|
|
|
// Second step has content
|
|
if steps[1].Instructions != "Has content." {
|
|
t.Errorf("step[1].Instructions = %q", steps[1].Instructions)
|
|
}
|
|
}
|
|
|
|
func TestValidateMolecule_SimpleCycle(t *testing.T) {
|
|
// A -> B -> A (simple 2-node cycle)
|
|
mol := &Issue{
|
|
ID: "mol-xyz",
|
|
Type: "molecule",
|
|
Description: `## Step: a
|
|
First step.
|
|
Needs: b
|
|
|
|
## Step: b
|
|
Second step.
|
|
Needs: a`,
|
|
}
|
|
|
|
err := ValidateMolecule(mol)
|
|
if err == nil {
|
|
t.Error("ValidateMolecule() = nil, want error for cycle")
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), "cycle") {
|
|
t.Errorf("error %q should mention 'cycle'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestValidateMolecule_LongerCycle(t *testing.T) {
|
|
// A -> B -> C -> A (3-node cycle)
|
|
mol := &Issue{
|
|
ID: "mol-xyz",
|
|
Type: "molecule",
|
|
Description: `## Step: a
|
|
First step.
|
|
Needs: c
|
|
|
|
## Step: b
|
|
Second step.
|
|
Needs: a
|
|
|
|
## Step: c
|
|
Third step.
|
|
Needs: b`,
|
|
}
|
|
|
|
err := ValidateMolecule(mol)
|
|
if err == nil {
|
|
t.Error("ValidateMolecule() = nil, want error for cycle")
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), "cycle") {
|
|
t.Errorf("error %q should mention 'cycle'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestValidateMolecule_DiamondNoCycle(t *testing.T) {
|
|
// Diamond pattern: A -> B, A -> C, B -> D, C -> D
|
|
// This has no cycle, should pass
|
|
mol := &Issue{
|
|
ID: "mol-xyz",
|
|
Type: "molecule",
|
|
Description: `## Step: a
|
|
Root step.
|
|
|
|
## Step: b
|
|
Branch 1.
|
|
Needs: a
|
|
|
|
## Step: c
|
|
Branch 2.
|
|
Needs: a
|
|
|
|
## Step: d
|
|
Merge point.
|
|
Needs: b, c`,
|
|
}
|
|
|
|
err := ValidateMolecule(mol)
|
|
if err != nil {
|
|
t.Errorf("ValidateMolecule() = %v, want nil (diamond has no cycle)", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateMolecule_CycleInSubgraph(t *testing.T) {
|
|
// Root -> A, A -> B -> C -> A (cycle not involving root)
|
|
mol := &Issue{
|
|
ID: "mol-xyz",
|
|
Type: "molecule",
|
|
Description: `## Step: root
|
|
Starting point.
|
|
|
|
## Step: a
|
|
First in cycle.
|
|
Needs: root, c
|
|
|
|
## Step: b
|
|
Second in cycle.
|
|
Needs: a
|
|
|
|
## Step: c
|
|
Third in cycle.
|
|
Needs: b`,
|
|
}
|
|
|
|
err := ValidateMolecule(mol)
|
|
if err == nil {
|
|
t.Error("ValidateMolecule() = nil, want error for cycle in subgraph")
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_WithType(t *testing.T) {
|
|
desc := `## Step: await-signal
|
|
Wait for a wake signal before proceeding.
|
|
Type: wait
|
|
|
|
## Step: check-reality
|
|
Check for work to do.
|
|
Type: task
|
|
Needs: await-signal
|
|
|
|
## Step: work
|
|
Do the actual work (default type).
|
|
Needs: check-reality`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 3 {
|
|
t.Fatalf("expected 3 steps, got %d", len(steps))
|
|
}
|
|
|
|
// await-signal has type wait
|
|
if steps[0].Type != "wait" {
|
|
t.Errorf("step[0].Type = %q, want wait", steps[0].Type)
|
|
}
|
|
|
|
// check-reality has explicit type task
|
|
if steps[1].Type != "task" {
|
|
t.Errorf("step[1].Type = %q, want task", steps[1].Type)
|
|
}
|
|
|
|
// work has no type specified (empty string, default)
|
|
if steps[2].Type != "" {
|
|
t.Errorf("step[2].Type = %q, want empty (default)", steps[2].Type)
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_WithBackoff(t *testing.T) {
|
|
desc := `## Step: await-signal
|
|
Wait for a wake signal with exponential backoff.
|
|
Type: wait
|
|
Backoff: base=30s, multiplier=2, max=10m
|
|
|
|
## Step: check-reality
|
|
Check for work.
|
|
Needs: await-signal`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 2 {
|
|
t.Fatalf("expected 2 steps, got %d", len(steps))
|
|
}
|
|
|
|
// await-signal has backoff config
|
|
if steps[0].Backoff == nil {
|
|
t.Fatal("step[0].Backoff is nil, want BackoffConfig")
|
|
}
|
|
if steps[0].Backoff.Base != "30s" {
|
|
t.Errorf("step[0].Backoff.Base = %q, want 30s", steps[0].Backoff.Base)
|
|
}
|
|
if steps[0].Backoff.Multiplier != 2 {
|
|
t.Errorf("step[0].Backoff.Multiplier = %d, want 2", steps[0].Backoff.Multiplier)
|
|
}
|
|
if steps[0].Backoff.Max != "10m" {
|
|
t.Errorf("step[0].Backoff.Max = %q, want 10m", steps[0].Backoff.Max)
|
|
}
|
|
|
|
// check-reality has no backoff
|
|
if steps[1].Backoff != nil {
|
|
t.Errorf("step[1].Backoff = %+v, want nil", steps[1].Backoff)
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_BackoffDefaultMultiplier(t *testing.T) {
|
|
desc := `## Step: wait-step
|
|
Simple wait.
|
|
Type: wait
|
|
Backoff: base=1m, max=30m`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 1 {
|
|
t.Fatalf("expected 1 step, got %d", len(steps))
|
|
}
|
|
|
|
if steps[0].Backoff == nil {
|
|
t.Fatal("step[0].Backoff is nil, want BackoffConfig")
|
|
}
|
|
// Default multiplier is 2
|
|
if steps[0].Backoff.Multiplier != 2 {
|
|
t.Errorf("step[0].Backoff.Multiplier = %d, want 2 (default)", steps[0].Backoff.Multiplier)
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_BackoffIncomplete(t *testing.T) {
|
|
desc := `## Step: bad-backoff
|
|
Missing base.
|
|
Backoff: multiplier=3, max=1h`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 1 {
|
|
t.Fatalf("expected 1 step, got %d", len(steps))
|
|
}
|
|
|
|
// Backoff without base should be nil
|
|
if steps[0].Backoff != nil {
|
|
t.Errorf("step[0].Backoff = %+v, want nil (missing base)", steps[0].Backoff)
|
|
}
|
|
}
|
|
|
|
func TestParseMoleculeSteps_TypeCaseInsensitive(t *testing.T) {
|
|
desc := `## Step: step1
|
|
First step.
|
|
TYPE: WAIT
|
|
|
|
## Step: step2
|
|
Second step.
|
|
type: Task
|
|
Needs: step1`
|
|
|
|
steps, err := ParseMoleculeSteps(desc)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(steps) != 2 {
|
|
t.Fatalf("expected 2 steps, got %d", len(steps))
|
|
}
|
|
|
|
// Type is normalized to lowercase
|
|
if steps[0].Type != "wait" {
|
|
t.Errorf("step[0].Type = %q, want wait", steps[0].Type)
|
|
}
|
|
if steps[1].Type != "task" {
|
|
t.Errorf("step[1].Type = %q, want task", steps[1].Type)
|
|
}
|
|
}
|