Implement computed range expansion for loops (gt-8tmz.27)

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>
This commit is contained in:
Steve Yegge
2025-12-25 17:48:35 -08:00
parent 258d183e25
commit 030838cfde
5 changed files with 906 additions and 14 deletions

View File

@@ -822,3 +822,159 @@ func TestApplyGates_Immutability(t *testing.T) {
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)
}
})
}
}