feat: Add condition evaluator for gates and loops (gt-8tmz.7)

Implements mechanical condition evaluation for formula control flow:
- Step status checks: step.status == 'complete'
- Step output access: step.output.approved == true
- Aggregates: children(step).all(status == 'complete'), steps.complete >= 3
- External checks: file.exists('go.mod'), env.CI == 'true'

Conditions are intentionally limited to keep evaluation decidable.
No arbitrary code execution allowed.

🤖 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 13:54:21 -08:00
parent d9bf695791
commit f56f3615e8
2 changed files with 973 additions and 0 deletions

View File

@@ -0,0 +1,536 @@
// Package formula provides condition evaluation for gates and loops.
//
// Conditions are intentionally limited to keep evaluation decidable:
// - Step status checks: step.status == 'complete'
// - Step output access: step.output.approved == true
// - Aggregates: children(step).all(status == 'complete')
// - External checks: file.exists('go.mod'), env.CI == 'true'
//
// No arbitrary code execution is allowed.
package formula
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
)
// ConditionResult represents the result of evaluating a condition.
type ConditionResult struct {
// Satisfied is true if the condition is met.
Satisfied bool
// Reason explains why the condition is satisfied or not.
Reason string
}
// StepState represents the runtime state of a step for condition evaluation.
type StepState struct {
// ID is the step identifier.
ID string
// Status is the step status: pending, in_progress, complete, failed.
Status string
// Output is the structured output from the step (if complete).
// Keys are dot-separated paths, values are the output values.
Output map[string]interface{}
// Children are the child step states (for aggregate conditions).
Children []*StepState
}
// ConditionContext provides the evaluation context for conditions.
type ConditionContext struct {
// Steps maps step ID to step state.
Steps map[string]*StepState
// CurrentStep is the step being gated (for relative references).
CurrentStep string
// Vars are the formula variables (for variable substitution).
Vars map[string]string
}
// Operator represents a comparison operator.
type Operator string
const (
OpEqual Operator = "=="
OpNotEqual Operator = "!="
OpGreater Operator = ">"
OpGreaterEqual Operator = ">="
OpLess Operator = "<"
OpLessEqual Operator = "<="
)
// Condition represents a parsed condition expression.
type Condition struct {
// Raw is the original condition string.
Raw string
// Type is the condition type: field, aggregate, external.
Type ConditionType
// For field conditions:
StepRef string // Step ID reference (e.g., "review", "step" for current)
Field string // Field path (e.g., "status", "output.approved")
Operator Operator // Comparison operator
Value string // Expected value
// For aggregate conditions:
AggregateFunc string // Function: all, any, count
AggregateOver string // What to aggregate: children, descendants, steps
// For external conditions:
ExternalType string // file.exists, env
ExternalArg string // Argument (path or env var name)
}
// ConditionType categorizes conditions.
type ConditionType string
const (
ConditionTypeField ConditionType = "field"
ConditionTypeAggregate ConditionType = "aggregate"
ConditionTypeExternal ConditionType = "external"
)
// Patterns for parsing conditions.
var (
// step.status == 'complete' or review.output.approved == true or test.output.errors.count == 0
fieldPattern = regexp.MustCompile(`^(\w+(?:\.\w+)*)\s*([=!<>]+)\s*(.+)$`)
// children(step).all(status == 'complete')
aggregatePattern = regexp.MustCompile(`^(children|descendants|steps)\((\w+)\)\.(all|any|count)\((.+)\)(.*)$`)
// file.exists('go.mod')
fileExistsPattern = regexp.MustCompile(`^file\.exists\(['"](.+)['"]\)$`)
// env.CI == 'true'
envPattern = regexp.MustCompile(`^env\.(\w+)\s*([=!<>]+)\s*(.+)$`)
// steps.complete >= 3
stepsStatPattern = regexp.MustCompile(`^steps\.(\w+)\s*([=!<>]+)\s*(\d+)$`)
)
// ParseCondition parses a condition string into a Condition struct.
func ParseCondition(expr string) (*Condition, error) {
expr = strings.TrimSpace(expr)
if expr == "" {
return nil, fmt.Errorf("empty condition")
}
// Try file.exists pattern
if m := fileExistsPattern.FindStringSubmatch(expr); m != nil {
return &Condition{
Raw: expr,
Type: ConditionTypeExternal,
ExternalType: "file.exists",
ExternalArg: m[1],
}, nil
}
// Try env pattern
if m := envPattern.FindStringSubmatch(expr); m != nil {
return &Condition{
Raw: expr,
Type: ConditionTypeExternal,
ExternalType: "env",
ExternalArg: m[1],
Operator: Operator(m[2]),
Value: unquote(m[3]),
}, nil
}
// Try aggregate pattern: children(step).all(status == 'complete')
if m := aggregatePattern.FindStringSubmatch(expr); m != nil {
innerCond, err := ParseCondition(m[4])
if err != nil {
return nil, fmt.Errorf("parsing aggregate inner condition: %w", err)
}
cond := &Condition{
Raw: expr,
Type: ConditionTypeAggregate,
AggregateOver: m[1], // children, descendants, steps
StepRef: m[2], // step reference
AggregateFunc: m[3], // all, any, count
Field: innerCond.Field,
Operator: innerCond.Operator,
Value: innerCond.Value,
}
// Handle count comparison: children(x).count(...) >= 3
if m[5] != "" {
countMatch := regexp.MustCompile(`\s*([=!<>]+)\s*(\d+)$`).FindStringSubmatch(m[5])
if countMatch != nil {
cond.AggregateFunc = "count"
cond.Operator = Operator(countMatch[1])
cond.Value = countMatch[2]
}
}
return cond, nil
}
// Try steps.stat pattern: steps.complete >= 3
if m := stepsStatPattern.FindStringSubmatch(expr); m != nil {
return &Condition{
Raw: expr,
Type: ConditionTypeAggregate,
AggregateOver: "steps",
AggregateFunc: "count",
Field: m[1], // complete, failed, etc.
Operator: Operator(m[2]),
Value: m[3],
}, nil
}
// Try field pattern: step.status == 'complete' or step.output.approved == true
if m := fieldPattern.FindStringSubmatch(expr); m != nil {
fieldPath := m[1]
parts := strings.SplitN(fieldPath, ".", 2)
stepRef := "step" // default to current step
field := fieldPath
if len(parts) >= 2 {
// Could be:
// - step.status (keyword "step" + field)
// - output.field (keyword "output" + path, relative to current step)
// - review.status (step name + field)
// - review.output.approved (step name + output.path)
if parts[0] == "step" {
// step.status or step.output.approved
field = parts[1]
} else if parts[0] == "output" {
// output.field (relative to current step)
field = fieldPath // keep as output.field
} else {
// step_name.field or step_name.output.path
stepRef = parts[0]
field = parts[1]
}
}
return &Condition{
Raw: expr,
Type: ConditionTypeField,
StepRef: stepRef,
Field: field,
Operator: Operator(m[2]),
Value: unquote(m[3]),
}, nil
}
return nil, fmt.Errorf("unrecognized condition format: %s", expr)
}
// Evaluate evaluates the condition against the given context.
func (c *Condition) Evaluate(ctx *ConditionContext) (*ConditionResult, error) {
switch c.Type {
case ConditionTypeField:
return c.evaluateField(ctx)
case ConditionTypeAggregate:
return c.evaluateAggregate(ctx)
case ConditionTypeExternal:
return c.evaluateExternal(ctx)
default:
return nil, fmt.Errorf("unknown condition type: %s", c.Type)
}
}
func (c *Condition) evaluateField(ctx *ConditionContext) (*ConditionResult, error) {
// Resolve step reference
stepID := c.StepRef
if stepID == "step" {
stepID = ctx.CurrentStep
}
step, ok := ctx.Steps[stepID]
if !ok {
return &ConditionResult{
Satisfied: false,
Reason: fmt.Sprintf("step %q not found", stepID),
}, nil
}
// Get the field value
var actual interface{}
if c.Field == "status" {
actual = step.Status
} else if strings.HasPrefix(c.Field, "output.") {
path := strings.TrimPrefix(c.Field, "output.")
actual = getNestedValue(step.Output, path)
} else {
return nil, fmt.Errorf("unknown field: %s", c.Field)
}
// Compare
satisfied, reason := compare(actual, c.Operator, c.Value)
return &ConditionResult{
Satisfied: satisfied,
Reason: reason,
}, nil
}
func (c *Condition) evaluateAggregate(ctx *ConditionContext) (*ConditionResult, error) {
// Get the set of steps to aggregate over
var steps []*StepState
switch c.AggregateOver {
case "children":
stepID := c.StepRef
if stepID == "step" {
stepID = ctx.CurrentStep
}
parent, ok := ctx.Steps[stepID]
if !ok {
return &ConditionResult{
Satisfied: false,
Reason: fmt.Sprintf("step %q not found", stepID),
}, nil
}
steps = parent.Children
case "steps":
// All steps in context
for _, s := range ctx.Steps {
steps = append(steps, s)
}
case "descendants":
stepID := c.StepRef
if stepID == "step" {
stepID = ctx.CurrentStep
}
parent, ok := ctx.Steps[stepID]
if !ok {
return &ConditionResult{
Satisfied: false,
Reason: fmt.Sprintf("step %q not found", stepID),
}, nil
}
steps = collectDescendants(parent)
}
// Apply the aggregate function
switch c.AggregateFunc {
case "all":
for _, s := range steps {
satisfied, _ := matchStep(s, c.Field, c.Operator, c.Value)
if !satisfied {
return &ConditionResult{
Satisfied: false,
Reason: fmt.Sprintf("step %q does not match: %s %s %s", s.ID, c.Field, c.Operator, c.Value),
}, nil
}
}
return &ConditionResult{
Satisfied: true,
Reason: fmt.Sprintf("all %d steps match", len(steps)),
}, nil
case "any":
for _, s := range steps {
satisfied, _ := matchStep(s, c.Field, c.Operator, c.Value)
if satisfied {
return &ConditionResult{
Satisfied: true,
Reason: fmt.Sprintf("step %q matches: %s %s %s", s.ID, c.Field, c.Operator, c.Value),
}, nil
}
}
return &ConditionResult{
Satisfied: false,
Reason: fmt.Sprintf("no steps match: %s %s %s", c.Field, c.Operator, c.Value),
}, nil
case "count":
count := 0
for _, s := range steps {
// For steps.complete pattern, field is the status to count
if c.AggregateOver == "steps" && (c.Field == "complete" || c.Field == "failed" || c.Field == "pending" || c.Field == "in_progress") {
if s.Status == c.Field {
count++
}
} else {
satisfied, _ := matchStep(s, c.Field, OpEqual, c.Value)
if satisfied {
count++
}
}
}
expected, _ := strconv.Atoi(c.Value)
satisfied, reason := compareInt(count, c.Operator, expected)
return &ConditionResult{
Satisfied: satisfied,
Reason: reason,
}, nil
}
return nil, fmt.Errorf("unknown aggregate function: %s", c.AggregateFunc)
}
func (c *Condition) evaluateExternal(ctx *ConditionContext) (*ConditionResult, error) {
switch c.ExternalType {
case "file.exists":
path := c.ExternalArg
// Substitute variables
for k, v := range ctx.Vars {
path = strings.ReplaceAll(path, "{{"+k+"}}", v)
}
_, err := os.Stat(path)
exists := err == nil
return &ConditionResult{
Satisfied: exists,
Reason: fmt.Sprintf("file %q exists: %v", path, exists),
}, nil
case "env":
actual := os.Getenv(c.ExternalArg)
satisfied, reason := compare(actual, c.Operator, c.Value)
return &ConditionResult{
Satisfied: satisfied,
Reason: reason,
}, nil
}
return nil, fmt.Errorf("unknown external type: %s", c.ExternalType)
}
// Helper functions
func unquote(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 {
if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') {
return s[1 : len(s)-1]
}
}
return s
}
func getNestedValue(m map[string]interface{}, path string) interface{} {
if m == nil {
return nil
}
parts := strings.Split(path, ".")
var current interface{} = m
for _, part := range parts {
if cm, ok := current.(map[string]interface{}); ok {
current = cm[part]
} else {
return nil
}
}
return current
}
func compare(actual interface{}, op Operator, expected string) (bool, string) {
actualStr := fmt.Sprintf("%v", actual)
switch op {
case OpEqual:
satisfied := actualStr == expected
return satisfied, fmt.Sprintf("%q %s %q: %v", actualStr, op, expected, satisfied)
case OpNotEqual:
satisfied := actualStr != expected
return satisfied, fmt.Sprintf("%q %s %q: %v", actualStr, op, expected, satisfied)
case OpGreater, OpGreaterEqual, OpLess, OpLessEqual:
// Try numeric comparison
actualNum, err1 := strconv.ParseFloat(actualStr, 64)
expectedNum, err2 := strconv.ParseFloat(expected, 64)
if err1 == nil && err2 == nil {
return compareFloat(actualNum, op, expectedNum)
}
// Fall back to string comparison
return compareString(actualStr, op, expected)
}
return false, fmt.Sprintf("unknown operator: %s", op)
}
func compareInt(actual int, op Operator, expected int) (bool, string) {
var satisfied bool
switch op {
case OpEqual:
satisfied = actual == expected
case OpNotEqual:
satisfied = actual != expected
case OpGreater:
satisfied = actual > expected
case OpGreaterEqual:
satisfied = actual >= expected
case OpLess:
satisfied = actual < expected
case OpLessEqual:
satisfied = actual <= expected
}
return satisfied, fmt.Sprintf("%d %s %d: %v", actual, op, expected, satisfied)
}
func compareFloat(actual float64, op Operator, expected float64) (bool, string) {
var satisfied bool
switch op {
case OpEqual:
satisfied = actual == expected
case OpNotEqual:
satisfied = actual != expected
case OpGreater:
satisfied = actual > expected
case OpGreaterEqual:
satisfied = actual >= expected
case OpLess:
satisfied = actual < expected
case OpLessEqual:
satisfied = actual <= expected
}
return satisfied, fmt.Sprintf("%v %s %v: %v", actual, op, expected, satisfied)
}
func compareString(actual string, op Operator, expected string) (bool, string) {
var satisfied bool
switch op {
case OpGreater:
satisfied = actual > expected
case OpGreaterEqual:
satisfied = actual >= expected
case OpLess:
satisfied = actual < expected
case OpLessEqual:
satisfied = actual <= expected
}
return satisfied, fmt.Sprintf("%q %s %q: %v", actual, op, expected, satisfied)
}
func matchStep(s *StepState, field string, op Operator, expected string) (bool, string) {
var actual interface{}
if field == "status" {
actual = s.Status
} else if strings.HasPrefix(field, "output.") {
path := strings.TrimPrefix(field, "output.")
actual = getNestedValue(s.Output, path)
} else {
// Direct field name might be a status shorthand
actual = s.Status
}
return compare(actual, op, expected)
}
func collectDescendants(s *StepState) []*StepState {
var result []*StepState
for _, child := range s.Children {
result = append(result, child)
result = append(result, collectDescendants(child)...)
}
return result
}
// EvaluateCondition is a convenience function that parses and evaluates a condition.
func EvaluateCondition(expr string, ctx *ConditionContext) (*ConditionResult, error) {
cond, err := ParseCondition(expr)
if err != nil {
return nil, err
}
return cond.Evaluate(ctx)
}

View File

@@ -0,0 +1,437 @@
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)
}
})
}
}