- Empty set 'all' returns false (avoids premature gate passing) - Add proper error handling for Atoi in count comparisons - Extract inline regex to package-level var (performance) - Handle == and != in compareString function - Add nil/bool value handling in compare function - Add tests for edge cases: empty children, nil output, bool values 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
530 lines
12 KiB
Go
530 lines
12 KiB
Go
package formula
|
|
|
|
import (
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseCondition_FieldConditions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
wantType ConditionType
|
|
wantStep string
|
|
wantOp Operator
|
|
}{
|
|
{
|
|
name: "step status",
|
|
expr: "step.status == 'complete'",
|
|
wantType: ConditionTypeField,
|
|
wantStep: "step",
|
|
wantOp: OpEqual,
|
|
},
|
|
{
|
|
name: "named step status",
|
|
expr: "review.status == 'complete'",
|
|
wantType: ConditionTypeField,
|
|
wantStep: "review",
|
|
wantOp: OpEqual,
|
|
},
|
|
{
|
|
name: "step output field",
|
|
expr: "review.output.approved == true",
|
|
wantType: ConditionTypeField,
|
|
wantStep: "review",
|
|
wantOp: OpEqual,
|
|
},
|
|
{
|
|
name: "not equal",
|
|
expr: "step.status != 'failed'",
|
|
wantType: ConditionTypeField,
|
|
wantStep: "step",
|
|
wantOp: OpNotEqual,
|
|
},
|
|
{
|
|
name: "nested output",
|
|
expr: "test.output.errors.count == 0",
|
|
wantType: ConditionTypeField,
|
|
wantStep: "test",
|
|
wantOp: OpEqual,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cond, err := ParseCondition(tt.expr)
|
|
if err != nil {
|
|
t.Fatalf("ParseCondition(%q) error: %v", tt.expr, err)
|
|
}
|
|
if cond.Type != tt.wantType {
|
|
t.Errorf("Type = %v, want %v", cond.Type, tt.wantType)
|
|
}
|
|
if cond.StepRef != tt.wantStep {
|
|
t.Errorf("StepRef = %v, want %v", cond.StepRef, tt.wantStep)
|
|
}
|
|
if cond.Operator != tt.wantOp {
|
|
t.Errorf("Operator = %v, want %v", cond.Operator, tt.wantOp)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseCondition_AggregateConditions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
wantFunc string
|
|
wantOver string
|
|
}{
|
|
{
|
|
name: "children all complete",
|
|
expr: "children(step).all(status == 'complete')",
|
|
wantFunc: "all",
|
|
wantOver: "children",
|
|
},
|
|
{
|
|
name: "children any failed",
|
|
expr: "children(review).any(status == 'failed')",
|
|
wantFunc: "any",
|
|
wantOver: "children",
|
|
},
|
|
{
|
|
name: "steps count",
|
|
expr: "steps.complete >= 3",
|
|
wantFunc: "count",
|
|
wantOver: "steps",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cond, err := ParseCondition(tt.expr)
|
|
if err != nil {
|
|
t.Fatalf("ParseCondition(%q) error: %v", tt.expr, err)
|
|
}
|
|
if cond.Type != ConditionTypeAggregate {
|
|
t.Errorf("Type = %v, want %v", cond.Type, ConditionTypeAggregate)
|
|
}
|
|
if cond.AggregateFunc != tt.wantFunc {
|
|
t.Errorf("AggregateFunc = %v, want %v", cond.AggregateFunc, tt.wantFunc)
|
|
}
|
|
if cond.AggregateOver != tt.wantOver {
|
|
t.Errorf("AggregateOver = %v, want %v", cond.AggregateOver, tt.wantOver)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseCondition_ExternalConditions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
wantExtType string
|
|
wantExtArg string
|
|
}{
|
|
{
|
|
name: "file exists single quotes",
|
|
expr: "file.exists('go.mod')",
|
|
wantExtType: "file.exists",
|
|
wantExtArg: "go.mod",
|
|
},
|
|
{
|
|
name: "file exists double quotes",
|
|
expr: `file.exists("package.json")`,
|
|
wantExtType: "file.exists",
|
|
wantExtArg: "package.json",
|
|
},
|
|
{
|
|
name: "env var",
|
|
expr: "env.CI == 'true'",
|
|
wantExtType: "env",
|
|
wantExtArg: "CI",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cond, err := ParseCondition(tt.expr)
|
|
if err != nil {
|
|
t.Fatalf("ParseCondition(%q) error: %v", tt.expr, err)
|
|
}
|
|
if cond.Type != ConditionTypeExternal {
|
|
t.Errorf("Type = %v, want %v", cond.Type, ConditionTypeExternal)
|
|
}
|
|
if cond.ExternalType != tt.wantExtType {
|
|
t.Errorf("ExternalType = %v, want %v", cond.ExternalType, tt.wantExtType)
|
|
}
|
|
if cond.ExternalArg != tt.wantExtArg {
|
|
t.Errorf("ExternalArg = %v, want %v", cond.ExternalArg, tt.wantExtArg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvaluateCondition_Field(t *testing.T) {
|
|
ctx := &ConditionContext{
|
|
CurrentStep: "test",
|
|
Steps: map[string]*StepState{
|
|
"design": {
|
|
ID: "design",
|
|
Status: "complete",
|
|
Output: map[string]interface{}{
|
|
"approved": true,
|
|
},
|
|
},
|
|
"test": {
|
|
ID: "test",
|
|
Status: "in_progress",
|
|
Output: map[string]interface{}{
|
|
"errors": map[string]interface{}{
|
|
"count": float64(0),
|
|
},
|
|
},
|
|
},
|
|
"review": {
|
|
ID: "review",
|
|
Status: "pending",
|
|
Output: map[string]interface{}{
|
|
"approved": false,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
wantSatis bool
|
|
}{
|
|
{
|
|
name: "step complete - true",
|
|
expr: "design.status == 'complete'",
|
|
wantSatis: true,
|
|
},
|
|
{
|
|
name: "step complete - false",
|
|
expr: "review.status == 'complete'",
|
|
wantSatis: false,
|
|
},
|
|
{
|
|
name: "current step status",
|
|
expr: "step.status == 'in_progress'",
|
|
wantSatis: true,
|
|
},
|
|
{
|
|
name: "output bool true",
|
|
expr: "design.output.approved == true",
|
|
wantSatis: true,
|
|
},
|
|
{
|
|
name: "output bool false",
|
|
expr: "review.output.approved == true",
|
|
wantSatis: false,
|
|
},
|
|
{
|
|
name: "nested output",
|
|
expr: "test.output.errors.count == 0",
|
|
wantSatis: true,
|
|
},
|
|
{
|
|
name: "not equal satisfied",
|
|
expr: "design.status != 'failed'",
|
|
wantSatis: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := EvaluateCondition(tt.expr, ctx)
|
|
if err != nil {
|
|
t.Fatalf("EvaluateCondition(%q) error: %v", tt.expr, err)
|
|
}
|
|
if result.Satisfied != tt.wantSatis {
|
|
t.Errorf("Satisfied = %v, want %v (reason: %s)", result.Satisfied, tt.wantSatis, result.Reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvaluateCondition_Aggregate(t *testing.T) {
|
|
ctx := &ConditionContext{
|
|
CurrentStep: "aggregate",
|
|
Steps: map[string]*StepState{
|
|
"parent": {
|
|
ID: "parent",
|
|
Status: "in_progress",
|
|
Children: []*StepState{
|
|
{ID: "child1", Status: "complete"},
|
|
{ID: "child2", Status: "complete"},
|
|
{ID: "child3", Status: "complete"},
|
|
},
|
|
},
|
|
"mixed": {
|
|
ID: "mixed",
|
|
Status: "in_progress",
|
|
Children: []*StepState{
|
|
{ID: "m1", Status: "complete"},
|
|
{ID: "m2", Status: "failed"},
|
|
{ID: "m3", Status: "pending"},
|
|
},
|
|
},
|
|
"step1": {ID: "step1", Status: "complete"},
|
|
"step2": {ID: "step2", Status: "complete"},
|
|
"step3": {ID: "step3", Status: "complete"},
|
|
"step4": {ID: "step4", Status: "pending"},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
wantSatis bool
|
|
}{
|
|
{
|
|
name: "all children complete - true",
|
|
expr: "children(parent).all(status == 'complete')",
|
|
wantSatis: true,
|
|
},
|
|
{
|
|
name: "all children complete - false",
|
|
expr: "children(mixed).all(status == 'complete')",
|
|
wantSatis: false,
|
|
},
|
|
{
|
|
name: "any children failed - true",
|
|
expr: "children(mixed).any(status == 'failed')",
|
|
wantSatis: true,
|
|
},
|
|
{
|
|
name: "any children failed - false",
|
|
expr: "children(parent).any(status == 'failed')",
|
|
wantSatis: false,
|
|
},
|
|
{
|
|
name: "steps count >= satisfied",
|
|
expr: "steps.complete >= 3",
|
|
wantSatis: true,
|
|
},
|
|
{
|
|
name: "steps count >= not satisfied",
|
|
expr: "steps.complete >= 5",
|
|
wantSatis: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := EvaluateCondition(tt.expr, ctx)
|
|
if err != nil {
|
|
t.Fatalf("EvaluateCondition(%q) error: %v", tt.expr, err)
|
|
}
|
|
if result.Satisfied != tt.wantSatis {
|
|
t.Errorf("Satisfied = %v, want %v (reason: %s)", result.Satisfied, tt.wantSatis, result.Reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvaluateCondition_External(t *testing.T) {
|
|
// Create a temp file for testing
|
|
tmpFile, err := os.CreateTemp("", "condition_test_*.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tmpFile.Close()
|
|
defer os.Remove(tmpFile.Name())
|
|
|
|
// Set an env var for testing
|
|
os.Setenv("TEST_CONDITION_VAR", "test_value")
|
|
defer os.Unsetenv("TEST_CONDITION_VAR")
|
|
|
|
ctx := &ConditionContext{
|
|
Vars: map[string]string{
|
|
"tempfile": tmpFile.Name(),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
wantSatis bool
|
|
}{
|
|
{
|
|
name: "file exists - true",
|
|
expr: "file.exists('" + tmpFile.Name() + "')",
|
|
wantSatis: true,
|
|
},
|
|
{
|
|
name: "file exists - false",
|
|
expr: "file.exists('/nonexistent/file/path')",
|
|
wantSatis: false,
|
|
},
|
|
{
|
|
name: "env var equals",
|
|
expr: "env.TEST_CONDITION_VAR == 'test_value'",
|
|
wantSatis: true,
|
|
},
|
|
{
|
|
name: "env var not equals",
|
|
expr: "env.TEST_CONDITION_VAR == 'wrong'",
|
|
wantSatis: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := EvaluateCondition(tt.expr, ctx)
|
|
if err != nil {
|
|
t.Fatalf("EvaluateCondition(%q) error: %v", tt.expr, err)
|
|
}
|
|
if result.Satisfied != tt.wantSatis {
|
|
t.Errorf("Satisfied = %v, want %v (reason: %s)", result.Satisfied, tt.wantSatis, result.Reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseCondition_Errors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
}{
|
|
{
|
|
name: "empty",
|
|
expr: "",
|
|
},
|
|
{
|
|
name: "whitespace only",
|
|
expr: " ",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := ParseCondition(tt.expr)
|
|
if err == nil {
|
|
t.Errorf("ParseCondition(%q) expected error, got nil", tt.expr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCompareOperators(t *testing.T) {
|
|
tests := []struct {
|
|
actual string
|
|
op Operator
|
|
expected string
|
|
want bool
|
|
}{
|
|
{"5", OpGreater, "3", true},
|
|
{"5", OpGreater, "5", false},
|
|
{"5", OpGreaterEqual, "5", true},
|
|
{"3", OpLess, "5", true},
|
|
{"5", OpLess, "5", false},
|
|
{"5", OpLessEqual, "5", true},
|
|
{"abc", OpEqual, "abc", true},
|
|
{"abc", OpNotEqual, "def", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.op), func(t *testing.T) {
|
|
got, _ := compare(tt.actual, tt.op, tt.expected)
|
|
if got != tt.want {
|
|
t.Errorf("compare(%q, %s, %q) = %v, want %v", tt.actual, tt.op, tt.expected, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvaluateCondition_EmptyChildren(t *testing.T) {
|
|
ctx := &ConditionContext{
|
|
CurrentStep: "parent",
|
|
Steps: map[string]*StepState{
|
|
"parent": {
|
|
ID: "parent",
|
|
Status: "in_progress",
|
|
Children: []*StepState{}, // Empty children
|
|
},
|
|
},
|
|
}
|
|
|
|
// "all children complete" with no children should be false (not vacuous truth)
|
|
result, err := EvaluateCondition("children(parent).all(status == 'complete')", ctx)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.Satisfied {
|
|
t.Errorf("empty children 'all' should be false, got true")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateCondition_NilOutput(t *testing.T) {
|
|
ctx := &ConditionContext{
|
|
CurrentStep: "test",
|
|
Steps: map[string]*StepState{
|
|
"test": {
|
|
ID: "test",
|
|
Status: "complete",
|
|
Output: nil, // No output
|
|
},
|
|
},
|
|
}
|
|
|
|
// Comparing nil output field should not panic
|
|
result, err := EvaluateCondition("test.output.missing == 'value'", ctx)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.Satisfied {
|
|
t.Errorf("nil field comparison should be false")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateCondition_BoolOutput(t *testing.T) {
|
|
ctx := &ConditionContext{
|
|
CurrentStep: "test",
|
|
Steps: map[string]*StepState{
|
|
"test": {
|
|
ID: "test",
|
|
Status: "complete",
|
|
Output: map[string]interface{}{
|
|
"approved": true, // actual bool, not string
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := EvaluateCondition("test.output.approved == true", ctx)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !result.Satisfied {
|
|
t.Errorf("bool true should match 'true', got: %s", result.Reason)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateCondition_CountError(t *testing.T) {
|
|
ctx := &ConditionContext{
|
|
Steps: map[string]*StepState{
|
|
"s1": {ID: "s1", Status: "complete"},
|
|
},
|
|
}
|
|
|
|
// Directly construct a condition with invalid count value
|
|
// (regex validation normally prevents this, but test defensive code)
|
|
cond := &Condition{
|
|
Raw: "steps.complete >= notanumber",
|
|
Type: ConditionTypeAggregate,
|
|
AggregateOver: "steps",
|
|
AggregateFunc: "count",
|
|
Field: "complete",
|
|
Operator: OpGreaterEqual,
|
|
Value: "notanumber", // Invalid: not an integer
|
|
}
|
|
|
|
_, err := cond.Evaluate(ctx)
|
|
if err == nil {
|
|
t.Error("expected error for non-integer count value")
|
|
}
|
|
}
|