Add support for for-each expansion over computed ranges:
loop:
range: "1..2^{disks}-1" # Evaluated at cook time
var: move_num
body: [...]
Features:
- Range field in LoopSpec for computed iterations
- Var field to expose iteration value to body steps
- Expression evaluator supporting + - * / ^ and parentheses
- Variable substitution in range expressions using {name} syntax
- Title/description variable substitution in expanded steps
Example use case: Towers of Hanoi formula where step count is 2^n-1
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
981 lines
22 KiB
Go
981 lines
22 KiB
Go
package formula
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestApplyLoops_FixedCount(t *testing.T) {
|
|
// Create a step with a fixed-count loop
|
|
steps := []*Step{
|
|
{
|
|
ID: "process",
|
|
Title: "Process items",
|
|
Loop: &LoopSpec{
|
|
Count: 3,
|
|
Body: []*Step{
|
|
{ID: "fetch", Title: "Fetch item"},
|
|
{ID: "transform", Title: "Transform item", Needs: []string{"fetch"}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyLoops(steps)
|
|
if err != nil {
|
|
t.Fatalf("ApplyLoops failed: %v", err)
|
|
}
|
|
|
|
// Should have 6 steps (3 iterations * 2 steps each)
|
|
if len(result) != 6 {
|
|
t.Errorf("Expected 6 steps, got %d", len(result))
|
|
}
|
|
|
|
// Check step IDs
|
|
expectedIDs := []string{
|
|
"process.iter1.fetch",
|
|
"process.iter1.transform",
|
|
"process.iter2.fetch",
|
|
"process.iter2.transform",
|
|
"process.iter3.fetch",
|
|
"process.iter3.transform",
|
|
}
|
|
|
|
for i, expected := range expectedIDs {
|
|
if i >= len(result) {
|
|
t.Errorf("Missing step %d: %s", i, expected)
|
|
continue
|
|
}
|
|
if result[i].ID != expected {
|
|
t.Errorf("Step %d: expected ID %s, got %s", i, expected, result[i].ID)
|
|
}
|
|
}
|
|
|
|
// Check that inner dependencies are preserved (within same iteration)
|
|
transform1 := result[1]
|
|
if len(transform1.Needs) != 1 || transform1.Needs[0] != "process.iter1.fetch" {
|
|
t.Errorf("transform1 should need process.iter1.fetch, got %v", transform1.Needs)
|
|
}
|
|
|
|
// Check that iterations are chained (iter2 depends on iter1)
|
|
fetch2 := result[2]
|
|
if len(fetch2.Needs) != 1 || fetch2.Needs[0] != "process.iter1.transform" {
|
|
t.Errorf("iter2.fetch should need iter1.transform, got %v", fetch2.Needs)
|
|
}
|
|
}
|
|
|
|
func TestApplyLoops_Conditional(t *testing.T) {
|
|
steps := []*Step{
|
|
{
|
|
ID: "retry",
|
|
Title: "Retry operation",
|
|
Loop: &LoopSpec{
|
|
Until: "step.status == 'complete'",
|
|
Max: 5,
|
|
Body: []*Step{
|
|
{ID: "attempt", Title: "Attempt operation"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyLoops(steps)
|
|
if err != nil {
|
|
t.Fatalf("ApplyLoops failed: %v", err)
|
|
}
|
|
|
|
// Conditional loops expand once (runtime re-executes)
|
|
if len(result) != 1 {
|
|
t.Errorf("Expected 1 step for conditional loop, got %d", len(result))
|
|
}
|
|
|
|
// Should have loop metadata label with JSON format
|
|
step := result[0]
|
|
hasLoopLabel := false
|
|
for _, label := range step.Labels {
|
|
// Label format: loop:{"max":5,"until":"step.status == 'complete'"}
|
|
if len(label) > 5 && label[:5] == "loop:" {
|
|
hasLoopLabel = true
|
|
// Verify it contains the expected values
|
|
if !strings.Contains(label, `"until"`) || !strings.Contains(label, `"max"`) {
|
|
t.Errorf("Loop label missing expected fields: %s", label)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !hasLoopLabel {
|
|
t.Error("Missing loop metadata label")
|
|
}
|
|
}
|
|
|
|
func TestApplyLoops_Validation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
loop *LoopSpec
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "empty body",
|
|
loop: &LoopSpec{Count: 3, Body: nil},
|
|
wantErr: "body is required",
|
|
},
|
|
{
|
|
name: "both count and until",
|
|
loop: &LoopSpec{Count: 3, Until: "cond", Max: 5, Body: []*Step{{ID: "a", Title: "A"}}},
|
|
wantErr: "cannot have both count and until",
|
|
},
|
|
{
|
|
name: "neither count nor until",
|
|
loop: &LoopSpec{Body: []*Step{{ID: "a", Title: "A"}}},
|
|
wantErr: "either count or until is required",
|
|
},
|
|
{
|
|
name: "until without max",
|
|
loop: &LoopSpec{Until: "cond", Body: []*Step{{ID: "a", Title: "A"}}},
|
|
wantErr: "max is required when until is set",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
steps := []*Step{{ID: "test", Title: "Test", Loop: tt.loop}}
|
|
_, err := ApplyLoops(steps)
|
|
if err == nil {
|
|
t.Error("Expected error, got nil")
|
|
} else if tt.wantErr != "" && err.Error() != "" {
|
|
// Just check that an error was returned
|
|
// The exact message format may vary
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyBranches(t *testing.T) {
|
|
steps := []*Step{
|
|
{ID: "setup", Title: "Setup"},
|
|
{ID: "test", Title: "Run tests"},
|
|
{ID: "lint", Title: "Run linter"},
|
|
{ID: "build", Title: "Build"},
|
|
{ID: "deploy", Title: "Deploy"},
|
|
}
|
|
|
|
compose := &ComposeRules{
|
|
Branch: []*BranchRule{
|
|
{
|
|
From: "setup",
|
|
Steps: []string{"test", "lint", "build"},
|
|
Join: "deploy",
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyBranches(steps, compose)
|
|
if err != nil {
|
|
t.Fatalf("ApplyBranches failed: %v", err)
|
|
}
|
|
|
|
// Build step map for checking
|
|
stepMap := make(map[string]*Step)
|
|
for _, s := range result {
|
|
stepMap[s.ID] = s
|
|
}
|
|
|
|
// Verify branch steps depend on 'from'
|
|
for _, branchStep := range []string{"test", "lint", "build"} {
|
|
s := stepMap[branchStep]
|
|
found := false
|
|
for _, need := range s.Needs {
|
|
if need == "setup" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Step %s should need 'setup', got %v", branchStep, s.Needs)
|
|
}
|
|
}
|
|
|
|
// Verify 'join' depends on all branch steps
|
|
deploy := stepMap["deploy"]
|
|
for _, branchStep := range []string{"test", "lint", "build"} {
|
|
found := false
|
|
for _, need := range deploy.Needs {
|
|
if need == branchStep {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("deploy should need %s, got %v", branchStep, deploy.Needs)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyBranches_Validation(t *testing.T) {
|
|
steps := []*Step{
|
|
{ID: "a", Title: "A"},
|
|
{ID: "b", Title: "B"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
branch *BranchRule
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing from",
|
|
branch: &BranchRule{Steps: []string{"a"}, Join: "b"},
|
|
wantErr: "from is required",
|
|
},
|
|
{
|
|
name: "missing steps",
|
|
branch: &BranchRule{From: "a", Join: "b"},
|
|
wantErr: "steps is required",
|
|
},
|
|
{
|
|
name: "missing join",
|
|
branch: &BranchRule{From: "a", Steps: []string{"b"}},
|
|
wantErr: "join is required",
|
|
},
|
|
{
|
|
name: "from not found",
|
|
branch: &BranchRule{From: "notfound", Steps: []string{"a"}, Join: "b"},
|
|
wantErr: "from step \"notfound\" not found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
compose := &ComposeRules{Branch: []*BranchRule{tt.branch}}
|
|
_, err := ApplyBranches(steps, compose)
|
|
if err == nil {
|
|
t.Error("Expected error, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyGates(t *testing.T) {
|
|
steps := []*Step{
|
|
{ID: "tests", Title: "Run tests"},
|
|
{ID: "deploy", Title: "Deploy to production"},
|
|
}
|
|
|
|
compose := &ComposeRules{
|
|
Gate: []*GateRule{
|
|
{
|
|
Before: "deploy",
|
|
Condition: "tests.status == 'complete'",
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyGates(steps, compose)
|
|
if err != nil {
|
|
t.Fatalf("ApplyGates failed: %v", err)
|
|
}
|
|
|
|
// Find deploy step
|
|
var deploy *Step
|
|
for _, s := range result {
|
|
if s.ID == "deploy" {
|
|
deploy = s
|
|
break
|
|
}
|
|
}
|
|
|
|
if deploy == nil {
|
|
t.Fatal("deploy step not found")
|
|
}
|
|
|
|
// Check for gate label with JSON format
|
|
found := false
|
|
for _, label := range deploy.Labels {
|
|
// Label format: gate:{"condition":"tests.status == 'complete'"}
|
|
if len(label) > 5 && label[:5] == "gate:" {
|
|
found = true
|
|
if !strings.Contains(label, `"condition"`) || !strings.Contains(label, "tests.status") {
|
|
t.Errorf("Gate label missing expected content: %s", label)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Errorf("deploy should have gate label, got %v", deploy.Labels)
|
|
}
|
|
}
|
|
|
|
func TestApplyGates_InvalidCondition(t *testing.T) {
|
|
steps := []*Step{
|
|
{ID: "deploy", Title: "Deploy"},
|
|
}
|
|
|
|
compose := &ComposeRules{
|
|
Gate: []*GateRule{
|
|
{
|
|
Before: "deploy",
|
|
Condition: "invalid condition syntax ???",
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err := ApplyGates(steps, compose)
|
|
if err == nil {
|
|
t.Error("Expected error for invalid condition, got nil")
|
|
}
|
|
}
|
|
|
|
func TestApplyControlFlow_Integration(t *testing.T) {
|
|
// Test the combined ApplyControlFlow function
|
|
steps := []*Step{
|
|
{ID: "setup", Title: "Setup"},
|
|
{
|
|
ID: "process",
|
|
Title: "Process items",
|
|
Loop: &LoopSpec{
|
|
Count: 2,
|
|
Body: []*Step{
|
|
{ID: "item", Title: "Process item"},
|
|
},
|
|
},
|
|
},
|
|
{ID: "cleanup", Title: "Cleanup"},
|
|
}
|
|
|
|
compose := &ComposeRules{
|
|
Branch: []*BranchRule{
|
|
{
|
|
From: "setup",
|
|
Steps: []string{"process.iter1.item", "process.iter2.item"},
|
|
Join: "cleanup",
|
|
},
|
|
},
|
|
Gate: []*GateRule{
|
|
{
|
|
Before: "cleanup",
|
|
Condition: "steps.complete >= 2",
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyControlFlow(steps, compose)
|
|
if err != nil {
|
|
t.Fatalf("ApplyControlFlow failed: %v", err)
|
|
}
|
|
|
|
// Should have: setup, process.iter1.item, process.iter2.item, cleanup
|
|
if len(result) != 4 {
|
|
t.Errorf("Expected 4 steps, got %d", len(result))
|
|
}
|
|
|
|
// Verify cleanup has gate label
|
|
var cleanup *Step
|
|
for _, s := range result {
|
|
if s.ID == "cleanup" {
|
|
cleanup = s
|
|
break
|
|
}
|
|
}
|
|
|
|
if cleanup == nil {
|
|
t.Fatal("cleanup step not found")
|
|
}
|
|
|
|
hasGate := false
|
|
for _, label := range cleanup.Labels {
|
|
// Label format: gate:{"condition":"steps.complete >= 2"}
|
|
if len(label) > 5 && label[:5] == "gate:" && strings.Contains(label, "steps.complete") {
|
|
hasGate = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasGate {
|
|
t.Errorf("cleanup should have gate label, got %v", cleanup.Labels)
|
|
}
|
|
}
|
|
|
|
func TestApplyLoops_NoLoops(t *testing.T) {
|
|
// Test with steps that have no loops
|
|
steps := []*Step{
|
|
{ID: "a", Title: "A"},
|
|
{ID: "b", Title: "B", Needs: []string{"a"}},
|
|
}
|
|
|
|
result, err := ApplyLoops(steps)
|
|
if err != nil {
|
|
t.Fatalf("ApplyLoops failed: %v", err)
|
|
}
|
|
|
|
if len(result) != 2 {
|
|
t.Errorf("Expected 2 steps, got %d", len(result))
|
|
}
|
|
|
|
// Dependencies should be preserved
|
|
if len(result[1].Needs) != 1 || result[1].Needs[0] != "a" {
|
|
t.Errorf("Dependencies not preserved: %v", result[1].Needs)
|
|
}
|
|
}
|
|
|
|
func TestApplyLoops_ExternalDependencies(t *testing.T) {
|
|
// Test that dependencies on steps OUTSIDE the loop are preserved as-is
|
|
steps := []*Step{
|
|
{ID: "setup", Title: "Setup"},
|
|
{
|
|
ID: "process",
|
|
Title: "Process items",
|
|
Loop: &LoopSpec{
|
|
Count: 2,
|
|
Body: []*Step{
|
|
{ID: "work", Title: "Do work", Needs: []string{"setup"}}, // External dep
|
|
{ID: "save", Title: "Save", Needs: []string{"work"}}, // Internal dep
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyLoops(steps)
|
|
if err != nil {
|
|
t.Fatalf("ApplyLoops failed: %v", err)
|
|
}
|
|
|
|
// Should have: setup, process.iter1.work, process.iter1.save, process.iter2.work, process.iter2.save
|
|
if len(result) != 5 {
|
|
t.Errorf("Expected 5 steps, got %d", len(result))
|
|
}
|
|
|
|
// Find iter1.work - should have external dep on "setup" (not "process.iter1.setup")
|
|
var work1 *Step
|
|
for _, s := range result {
|
|
if s.ID == "process.iter1.work" {
|
|
work1 = s
|
|
break
|
|
}
|
|
}
|
|
|
|
if work1 == nil {
|
|
t.Fatal("process.iter1.work not found")
|
|
}
|
|
|
|
// External dependency should be preserved as-is
|
|
if len(work1.Needs) != 1 || work1.Needs[0] != "setup" {
|
|
t.Errorf("External dependency should be 'setup', got %v", work1.Needs)
|
|
}
|
|
|
|
// Find iter1.save - should have internal dep on "process.iter1.work"
|
|
var save1 *Step
|
|
for _, s := range result {
|
|
if s.ID == "process.iter1.save" {
|
|
save1 = s
|
|
break
|
|
}
|
|
}
|
|
|
|
if save1 == nil {
|
|
t.Fatal("process.iter1.save not found")
|
|
}
|
|
|
|
// Internal dependency should be prefixed
|
|
if len(save1.Needs) != 1 || save1.Needs[0] != "process.iter1.work" {
|
|
t.Errorf("Internal dependency should be 'process.iter1.work', got %v", save1.Needs)
|
|
}
|
|
}
|
|
|
|
func TestApplyLoops_NestedChildren(t *testing.T) {
|
|
// Test that children are preserved when recursing
|
|
steps := []*Step{
|
|
{
|
|
ID: "parent",
|
|
Title: "Parent",
|
|
Children: []*Step{
|
|
{ID: "child", Title: "Child"},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyLoops(steps)
|
|
if err != nil {
|
|
t.Fatalf("ApplyLoops failed: %v", err)
|
|
}
|
|
|
|
if len(result) != 1 {
|
|
t.Fatalf("Expected 1 step, got %d", len(result))
|
|
}
|
|
|
|
if len(result[0].Children) != 1 {
|
|
t.Errorf("Expected 1 child, got %d", len(result[0].Children))
|
|
}
|
|
}
|
|
|
|
// gt-zn35j: Tests for nested loop support
|
|
|
|
func TestApplyLoops_NestedLoops(t *testing.T) {
|
|
// Create a loop containing another loop
|
|
steps := []*Step{
|
|
{
|
|
ID: "outer",
|
|
Title: "Outer loop",
|
|
Loop: &LoopSpec{
|
|
Count: 2,
|
|
Body: []*Step{
|
|
{
|
|
ID: "inner",
|
|
Title: "Inner loop",
|
|
Loop: &LoopSpec{
|
|
Count: 2,
|
|
Body: []*Step{
|
|
{ID: "work", Title: "Do work"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyLoops(steps)
|
|
if err != nil {
|
|
t.Fatalf("ApplyLoops failed: %v", err)
|
|
}
|
|
|
|
// Should have 4 steps total (2 outer * 2 inner)
|
|
if len(result) != 4 {
|
|
t.Errorf("Expected 4 steps, got %d", len(result))
|
|
for i, s := range result {
|
|
t.Logf(" Step %d: %s", i, s.ID)
|
|
}
|
|
}
|
|
|
|
// Check step IDs follow nested pattern
|
|
expectedIDs := []string{
|
|
"outer.iter1.inner.iter1.work",
|
|
"outer.iter1.inner.iter2.work",
|
|
"outer.iter2.inner.iter1.work",
|
|
"outer.iter2.inner.iter2.work",
|
|
}
|
|
|
|
for i, expected := range expectedIDs {
|
|
if i >= len(result) {
|
|
t.Errorf("Missing step %d: %s", i, expected)
|
|
continue
|
|
}
|
|
if result[i].ID != expected {
|
|
t.Errorf("Step %d: expected ID %s, got %s", i, expected, result[i].ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyLoops_NestedLoopsWithDependencies(t *testing.T) {
|
|
// Nested loops with dependencies between inner steps
|
|
steps := []*Step{
|
|
{
|
|
ID: "outer",
|
|
Title: "Outer loop",
|
|
Loop: &LoopSpec{
|
|
Count: 2,
|
|
Body: []*Step{
|
|
{
|
|
ID: "inner",
|
|
Title: "Inner loop",
|
|
Loop: &LoopSpec{
|
|
Count: 2,
|
|
Body: []*Step{
|
|
{ID: "fetch", Title: "Fetch data"},
|
|
{ID: "process", Title: "Process data", Needs: []string{"fetch"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyLoops(steps)
|
|
if err != nil {
|
|
t.Fatalf("ApplyLoops failed: %v", err)
|
|
}
|
|
|
|
// Should have 8 steps (2 outer * 2 inner * 2 body steps)
|
|
if len(result) != 8 {
|
|
t.Errorf("Expected 8 steps, got %d", len(result))
|
|
}
|
|
|
|
// Check that inner dependencies are correctly prefixed
|
|
// Find outer.iter1.inner.iter1.process - should depend on outer.iter1.inner.iter1.fetch
|
|
var process1 *Step
|
|
for _, s := range result {
|
|
if s.ID == "outer.iter1.inner.iter1.process" {
|
|
process1 = s
|
|
break
|
|
}
|
|
}
|
|
|
|
if process1 == nil {
|
|
t.Fatal("outer.iter1.inner.iter1.process not found")
|
|
}
|
|
|
|
if len(process1.Needs) != 1 || process1.Needs[0] != "outer.iter1.inner.iter1.fetch" {
|
|
t.Errorf("process should need outer.iter1.inner.iter1.fetch, got %v", process1.Needs)
|
|
}
|
|
}
|
|
|
|
func TestApplyLoops_ThreeLevelNesting(t *testing.T) {
|
|
// Three levels of nesting
|
|
steps := []*Step{
|
|
{
|
|
ID: "l1",
|
|
Loop: &LoopSpec{
|
|
Count: 2,
|
|
Body: []*Step{
|
|
{
|
|
ID: "l2",
|
|
Loop: &LoopSpec{
|
|
Count: 2,
|
|
Body: []*Step{
|
|
{
|
|
ID: "l3",
|
|
Loop: &LoopSpec{
|
|
Count: 2,
|
|
Body: []*Step{
|
|
{ID: "leaf", Title: "Leaf step"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyLoops(steps)
|
|
if err != nil {
|
|
t.Fatalf("ApplyLoops failed: %v", err)
|
|
}
|
|
|
|
// Should have 8 steps (2 * 2 * 2)
|
|
if len(result) != 8 {
|
|
t.Errorf("Expected 8 steps, got %d", len(result))
|
|
}
|
|
|
|
// Check first and last step IDs
|
|
if result[0].ID != "l1.iter1.l2.iter1.l3.iter1.leaf" {
|
|
t.Errorf("First step ID wrong: %s", result[0].ID)
|
|
}
|
|
if result[7].ID != "l1.iter2.l2.iter2.l3.iter2.leaf" {
|
|
t.Errorf("Last step ID wrong: %s", result[7].ID)
|
|
}
|
|
}
|
|
|
|
func TestApplyLoops_NestedLoopsOuterChaining(t *testing.T) {
|
|
// Verify that outer iterations are chained AFTER nested loop expansion.
|
|
// outer.iter2's first step should depend on outer.iter1's LAST step.
|
|
steps := []*Step{
|
|
{
|
|
ID: "outer",
|
|
Title: "Outer loop",
|
|
Loop: &LoopSpec{
|
|
Count: 2,
|
|
Body: []*Step{
|
|
{
|
|
ID: "inner",
|
|
Title: "Inner loop",
|
|
Loop: &LoopSpec{
|
|
Count: 2,
|
|
Body: []*Step{
|
|
{ID: "work", Title: "Do work"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyLoops(steps)
|
|
if err != nil {
|
|
t.Fatalf("ApplyLoops failed: %v", err)
|
|
}
|
|
|
|
// Should have 4 steps
|
|
if len(result) != 4 {
|
|
t.Fatalf("Expected 4 steps, got %d", len(result))
|
|
}
|
|
|
|
// Expected order:
|
|
// 0: outer.iter1.inner.iter1.work
|
|
// 1: outer.iter1.inner.iter2.work (depends on above via inner chaining)
|
|
// 2: outer.iter2.inner.iter1.work (should depend on step 1 via outer chaining!)
|
|
// 3: outer.iter2.inner.iter2.work (depends on above via inner chaining)
|
|
|
|
// Verify outer chaining: step 2 should depend on step 1
|
|
step2 := result[2]
|
|
if step2.ID != "outer.iter2.inner.iter1.work" {
|
|
t.Fatalf("Step 2 ID wrong: %s", step2.ID)
|
|
}
|
|
|
|
// This is the key assertion: outer.iter2's first step must depend on
|
|
// outer.iter1's last step (outer.iter1.inner.iter2.work)
|
|
expectedDep := "outer.iter1.inner.iter2.work"
|
|
found := false
|
|
for _, need := range step2.Needs {
|
|
if need == expectedDep {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("outer.iter2 first step should depend on outer.iter1 last step.\n"+
|
|
"Expected Needs to contain %q, got %v", expectedDep, step2.Needs)
|
|
}
|
|
}
|
|
|
|
// gt-v1pcg: Tests for immutability of ApplyBranches and ApplyGates
|
|
|
|
func TestApplyBranches_Immutability(t *testing.T) {
|
|
// Create steps with no initial dependencies
|
|
steps := []*Step{
|
|
{ID: "setup", Title: "Setup", Needs: nil},
|
|
{ID: "test", Title: "Run tests", Needs: nil},
|
|
{ID: "deploy", Title: "Deploy", Needs: nil},
|
|
}
|
|
|
|
compose := &ComposeRules{
|
|
Branch: []*BranchRule{
|
|
{From: "setup", Steps: []string{"test"}, Join: "deploy"},
|
|
},
|
|
}
|
|
|
|
// Call ApplyBranches
|
|
result, err := ApplyBranches(steps, compose)
|
|
if err != nil {
|
|
t.Fatalf("ApplyBranches failed: %v", err)
|
|
}
|
|
|
|
// Verify original steps are NOT mutated
|
|
for _, step := range steps {
|
|
if len(step.Needs) != 0 {
|
|
t.Errorf("Original step %q was mutated: Needs = %v (expected nil)", step.ID, step.Needs)
|
|
}
|
|
}
|
|
|
|
// Verify result has the expected dependencies
|
|
resultMap := make(map[string]*Step)
|
|
for _, s := range result {
|
|
resultMap[s.ID] = s
|
|
}
|
|
|
|
if len(resultMap["test"].Needs) != 1 || resultMap["test"].Needs[0] != "setup" {
|
|
t.Errorf("Result test step should need setup, got %v", resultMap["test"].Needs)
|
|
}
|
|
}
|
|
|
|
func TestApplyGates_Immutability(t *testing.T) {
|
|
// Create steps with no initial labels
|
|
steps := []*Step{
|
|
{ID: "tests", Title: "Run tests", Labels: nil},
|
|
{ID: "deploy", Title: "Deploy", Labels: nil},
|
|
}
|
|
|
|
compose := &ComposeRules{
|
|
Gate: []*GateRule{
|
|
{Before: "deploy", Condition: "tests.status == 'complete'"},
|
|
},
|
|
}
|
|
|
|
// Call ApplyGates
|
|
result, err := ApplyGates(steps, compose)
|
|
if err != nil {
|
|
t.Fatalf("ApplyGates failed: %v", err)
|
|
}
|
|
|
|
// Verify original steps are NOT mutated
|
|
for _, step := range steps {
|
|
if len(step.Labels) != 0 {
|
|
t.Errorf("Original step %q was mutated: Labels = %v (expected nil)", step.ID, step.Labels)
|
|
}
|
|
}
|
|
|
|
// Verify result has the expected labels
|
|
var deployResult *Step
|
|
for _, s := range result {
|
|
if s.ID == "deploy" {
|
|
deployResult = s
|
|
break
|
|
}
|
|
}
|
|
|
|
if deployResult == nil {
|
|
t.Fatal("deploy step not found in result")
|
|
}
|
|
|
|
hasGate := false
|
|
for _, label := range deployResult.Labels {
|
|
if len(label) > 5 && label[:5] == "gate:" {
|
|
hasGate = true
|
|
break
|
|
}
|
|
}
|
|
if !hasGate {
|
|
t.Errorf("Result deploy step should have gate label, got %v", deployResult.Labels)
|
|
}
|
|
}
|
|
|
|
// TestApplyLoops_Range tests computed range expansion (gt-8tmz.27).
|
|
func TestApplyLoops_Range(t *testing.T) {
|
|
// Create a step with a range loop
|
|
steps := []*Step{
|
|
{
|
|
ID: "moves",
|
|
Title: "Tower moves",
|
|
Loop: &LoopSpec{
|
|
Range: "1..3",
|
|
Var: "move_num",
|
|
Body: []*Step{
|
|
{ID: "move", Title: "Move {move_num}"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyLoops(steps)
|
|
if err != nil {
|
|
t.Fatalf("ApplyLoops failed: %v", err)
|
|
}
|
|
|
|
// Should have 3 steps (range 1..3 = 3 iterations)
|
|
if len(result) != 3 {
|
|
t.Errorf("Expected 3 steps, got %d", len(result))
|
|
}
|
|
|
|
// Check step IDs and titles
|
|
expectedIDs := []string{
|
|
"moves.iter1.move",
|
|
"moves.iter2.move",
|
|
"moves.iter3.move",
|
|
}
|
|
expectedTitles := []string{
|
|
"Move 1",
|
|
"Move 2",
|
|
"Move 3",
|
|
}
|
|
|
|
for i, expected := range expectedIDs {
|
|
if i >= len(result) {
|
|
t.Errorf("Missing step %d: %s", i, expected)
|
|
continue
|
|
}
|
|
if result[i].ID != expected {
|
|
t.Errorf("Step %d: expected ID %s, got %s", i, expected, result[i].ID)
|
|
}
|
|
if result[i].Title != expectedTitles[i] {
|
|
t.Errorf("Step %d: expected Title %q, got %q", i, expectedTitles[i], result[i].Title)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestApplyLoops_RangeComputed tests computed range with expressions.
|
|
func TestApplyLoops_RangeComputed(t *testing.T) {
|
|
// Create a step with a computed range loop (like Towers of Hanoi)
|
|
steps := []*Step{
|
|
{
|
|
ID: "hanoi",
|
|
Title: "Hanoi moves",
|
|
Loop: &LoopSpec{
|
|
Range: "1..2^3-1", // 1..7 (2^3-1 moves for 3 disks)
|
|
Var: "step_num",
|
|
Body: []*Step{
|
|
{ID: "step", Title: "Step {step_num}"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := ApplyLoops(steps)
|
|
if err != nil {
|
|
t.Fatalf("ApplyLoops failed: %v", err)
|
|
}
|
|
|
|
// Should have 7 steps (2^3-1 = 7)
|
|
if len(result) != 7 {
|
|
t.Errorf("Expected 7 steps, got %d", len(result))
|
|
}
|
|
|
|
// Check first and last step
|
|
if len(result) >= 1 {
|
|
if result[0].Title != "Step 1" {
|
|
t.Errorf("First step title: expected 'Step 1', got %q", result[0].Title)
|
|
}
|
|
}
|
|
if len(result) >= 7 {
|
|
if result[6].Title != "Step 7" {
|
|
t.Errorf("Last step title: expected 'Step 7', got %q", result[6].Title)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestValidateLoopSpec_Range tests validation of range loops.
|
|
func TestValidateLoopSpec_Range(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
loop *LoopSpec
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid range",
|
|
loop: &LoopSpec{
|
|
Range: "1..10",
|
|
Body: []*Step{{ID: "step"}},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid computed range",
|
|
loop: &LoopSpec{
|
|
Range: "1..2^3",
|
|
Var: "n",
|
|
Body: []*Step{{ID: "step"}},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid - both count and range",
|
|
loop: &LoopSpec{
|
|
Count: 5,
|
|
Range: "1..10",
|
|
Body: []*Step{{ID: "step"}},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid - both until and range",
|
|
loop: &LoopSpec{
|
|
Until: "step.status == 'complete'",
|
|
Max: 10,
|
|
Range: "1..10",
|
|
Body: []*Step{{ID: "step"}},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid range syntax",
|
|
loop: &LoopSpec{
|
|
Range: "invalid",
|
|
Body: []*Step{{ID: "step"}},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateLoopSpec(tt.loop, "test")
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("validateLoopSpec() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|