", "right": 100000} * - 복합 조건: {"and": [{"stepResult": "login", "is": "success"}, "{{login.role}} == 'admin'"]} */ class ConditionEvaluator { private VariableBinder $binder; /** * 스텝 실행 결과 맵 (stepId => ['success' => bool, 'status' => int, 'skipped' => bool]) */ private array $stepResults = []; /** * 평가 로그 */ private array $evaluationLog = []; public function __construct(?VariableBinder $binder = null) { $this->binder = $binder ?? new VariableBinder; } /** * VariableBinder 설정 */ public function setBinder(VariableBinder $binder): void { $this->binder = $binder; } /** * 스텝 결과 등록 * * @param string $stepId 스텝 ID * @param array $result 스텝 실행 결과 */ public function setStepResult(string $stepId, array $result): void { $this->stepResults[$stepId] = [ 'success' => $result['success'] ?? false, 'status' => $result['response']['status'] ?? 0, 'skipped' => $result['skipped'] ?? false, 'executed' => ! ($result['skipped'] ?? false), 'response' => $result['response']['body'] ?? null, 'extracted' => $result['extracted'] ?? [], ]; } /** * 조건 평가 * * @param mixed $condition 조건 (문자열 또는 배열) * @return array ['passed' => bool, 'reason' => string, 'evaluated' => string] */ public function evaluate(mixed $condition): array { $this->evaluationLog = []; if ($condition === null || $condition === true) { return $this->result(true, 'No condition (always run)'); } if ($condition === false) { return $this->result(false, 'Condition is explicitly false'); } try { if (is_string($condition)) { return $this->evaluateStringExpression($condition); } if (is_array($condition)) { return $this->evaluateArrayCondition($condition); } return $this->result(false, 'Invalid condition type: '.gettype($condition)); } catch (\Exception $e) { return $this->result(false, 'Evaluation error: '.$e->getMessage()); } } /** * 문자열 표현식 평가 * * 형식: "{{step.field}} op value" * 예: "{{login.success}} == true" * "{{order.total}} > 100000" * "{{user.role}} != 'guest'" */ private function evaluateStringExpression(string $expression): array { // 변수 바인딩 적용 $boundExpression = $this->binder->bind($expression); $this->log("Expression: {$expression} → {$boundExpression}"); // 연산자 패턴 매칭 $operators = ['===', '!==', '==', '!=', '>=', '<=', '>', '<', ' contains ', ' in ', ' matches ']; foreach ($operators as $op) { $trimmedOp = trim($op); if (str_contains($boundExpression, $op)) { $parts = explode($op, $boundExpression, 2); if (count($parts) === 2) { $left = $this->parseValue(trim($parts[0])); $right = $this->parseValue(trim($parts[1])); $result = $this->compare($left, $trimmedOp, $right); $this->log("Compare: {$left} {$trimmedOp} {$right} = ".($result ? 'true' : 'false')); return $this->result($result, "{$expression} → ".($result ? 'true' : 'false')); } } } // 연산자가 없으면 truthy 체크 $value = $this->parseValue($boundExpression); $result = $this->isTruthy($value); return $this->result($result, "Truthy check: {$boundExpression} → ".($result ? 'true' : 'false')); } /** * 배열 조건 평가 */ private function evaluateArrayCondition(array $condition): array { // 1. 논리 연산자: and, or, not if (isset($condition['and'])) { return $this->evaluateAnd($condition['and']); } if (isset($condition['or'])) { return $this->evaluateOr($condition['or']); } if (isset($condition['not'])) { $inner = $this->evaluate($condition['not']); return $this->result(! $inner['passed'], "NOT ({$inner['reason']})"); } // 2. 스텝 결과 조건 if (isset($condition['stepResult'])) { return $this->evaluateStepResult($condition); } // 3. 존재 확인 if (isset($condition['exists'])) { return $this->evaluateExists($condition['exists'], true); } if (isset($condition['notExists'])) { return $this->evaluateExists($condition['notExists'], false); } // 4. 빈 값 확인 if (isset($condition['isEmpty'])) { return $this->evaluateEmpty($condition['isEmpty'], true); } if (isset($condition['isNotEmpty'])) { return $this->evaluateEmpty($condition['isNotEmpty'], false); } // 5. 비교 연산 객체: {left, op, right} if (isset($condition['left']) && isset($condition['op'])) { return $this->evaluateComparisonObject($condition); } // 6. 타입 체크 if (isset($condition['isType'])) { return $this->evaluateType($condition['value'] ?? '', $condition['isType']); } // 7. 모든 조건 (all - and의 별칭) if (isset($condition['all'])) { return $this->evaluateAnd($condition['all']); } // 8. 하나라도 (any - or의 별칭) if (isset($condition['any'])) { return $this->evaluateOr($condition['any']); } return $this->result(false, 'Unknown condition structure: '.json_encode($condition)); } /** * AND 조건 평가 */ private function evaluateAnd(array $conditions): array { $reasons = []; foreach ($conditions as $i => $cond) { $result = $this->evaluate($cond); $reasons[] = "[{$i}] ".($result['passed'] ? '✓' : '✗').' '.$result['reason']; if (! $result['passed']) { return $this->result(false, 'AND failed: '.implode(', ', $reasons)); } } return $this->result(true, 'AND passed: '.implode(', ', $reasons)); } /** * OR 조건 평가 */ private function evaluateOr(array $conditions): array { $reasons = []; foreach ($conditions as $i => $cond) { $result = $this->evaluate($cond); $reasons[] = "[{$i}] ".($result['passed'] ? '✓' : '✗').' '.$result['reason']; if ($result['passed']) { return $this->result(true, 'OR passed: '.implode(', ', $reasons)); } } return $this->result(false, 'OR failed: '.implode(', ', $reasons)); } /** * 스텝 결과 조건 평가 * * {"stepResult": "login", "is": "success|failure|skipped|executed"} */ private function evaluateStepResult(array $condition): array { $stepId = $condition['stepResult']; $expected = $condition['is'] ?? 'success'; if (! isset($this->stepResults[$stepId])) { return $this->result(false, "Step '{$stepId}' has not been executed yet"); } $stepResult = $this->stepResults[$stepId]; $passed = match ($expected) { 'success' => $stepResult['success'] === true, 'failure', 'failed' => $stepResult['success'] === false && ! $stepResult['skipped'], 'skipped' => $stepResult['skipped'] === true, 'executed' => $stepResult['executed'] === true, default => false, }; $actual = $stepResult['skipped'] ? 'skipped' : ($stepResult['success'] ? 'success' : 'failure'); return $this->result( $passed, "Step '{$stepId}' is {$actual}, expected: {$expected}" ); } /** * 존재 확인 평가 */ private function evaluateExists(string $path, bool $shouldExist): array { $value = $this->binder->bind($path); // 바인딩 후에도 변수 패턴이 남아있으면 존재하지 않는 것 $exists = ! str_contains($value, '{{') && $value !== '' && $value !== null; $passed = $shouldExist ? $exists : ! $exists; $status = $shouldExist ? 'exists' : 'notExists'; return $this->result($passed, "{$path} {$status}: ".($exists ? 'yes' : 'no')); } /** * 빈 값 확인 평가 */ private function evaluateEmpty(string $path, bool $shouldBeEmpty): array { $value = $this->binder->bind($path); $parsedValue = $this->parseValue($value); $isEmpty = empty($parsedValue) || $parsedValue === '' || $parsedValue === [] || $parsedValue === null; $passed = $shouldBeEmpty ? $isEmpty : ! $isEmpty; return $this->result($passed, "{$path} isEmpty: ".($isEmpty ? 'yes' : 'no')); } /** * 비교 연산 객체 평가 * * {"left": "{{step.field}}", "op": "==", "right": "value"} */ private function evaluateComparisonObject(array $condition): array { $left = $this->binder->bind($condition['left'] ?? ''); $op = $condition['op'] ?? '=='; $right = $condition['right'] ?? null; // right도 변수일 수 있음 if (is_string($right) && str_contains($right, '{{')) { $right = $this->binder->bind($right); } $leftValue = $this->parseValue($left); $rightValue = is_string($right) ? $this->parseValue($right) : $right; $result = $this->compare($leftValue, $op, $rightValue); $leftDisplay = is_array($leftValue) ? json_encode($leftValue) : $leftValue; $rightDisplay = is_array($rightValue) ? json_encode($rightValue) : $rightValue; return $this->result($result, "{$leftDisplay} {$op} {$rightDisplay} → ".($result ? 'true' : 'false')); } /** * 타입 체크 평가 */ private function evaluateType(string $path, string $expectedType): array { $value = $this->binder->bind($path); $parsedValue = $this->parseValue($value); $actualType = gettype($parsedValue); $passed = match ($expectedType) { 'number', 'numeric' => is_numeric($parsedValue), 'integer', 'int' => is_int($parsedValue) || (is_string($parsedValue) && ctype_digit($parsedValue)), 'string' => is_string($parsedValue), 'array' => is_array($parsedValue), 'object' => is_object($parsedValue) || (is_array($parsedValue) && ! array_is_list($parsedValue)), 'boolean', 'bool' => is_bool($parsedValue) || in_array(strtolower((string) $parsedValue), ['true', 'false']), 'null' => $parsedValue === null || strtolower((string) $parsedValue) === 'null', default => $actualType === $expectedType, }; return $this->result($passed, "{$path} isType {$expectedType}: actual={$actualType}"); } /** * 값 비교 */ private function compare(mixed $left, string $op, mixed $right): bool { return match ($op) { '==', '=' => $left == $right, '===' => $left === $right, '!=', '<>' => $left != $right, '!==' => $left !== $right, '>' => is_numeric($left) && is_numeric($right) && $left > $right, '>=' => is_numeric($left) && is_numeric($right) && $left >= $right, '<' => is_numeric($left) && is_numeric($right) && $left < $right, '<=' => is_numeric($left) && is_numeric($right) && $left <= $right, 'contains' => $this->contains($left, $right), 'notContains', 'not_contains' => ! $this->contains($left, $right), 'in' => $this->in($left, $right), 'notIn', 'not_in' => ! $this->in($left, $right), 'startsWith', 'starts_with' => is_string($left) && is_string($right) && str_starts_with($left, $right), 'endsWith', 'ends_with' => is_string($left) && is_string($right) && str_ends_with($left, $right), 'matches', 'regex' => is_string($left) && is_string($right) && preg_match($right, $left), default => false, }; } /** * 포함 여부 확인 */ private function contains(mixed $haystack, mixed $needle): bool { if (is_string($haystack)) { return str_contains($haystack, (string) $needle); } if (is_array($haystack)) { return in_array($needle, $haystack); } return false; } /** * 배열 내 포함 여부 확인 */ private function in(mixed $needle, mixed $haystack): bool { if (! is_array($haystack)) { return false; } return in_array($needle, $haystack); } /** * 문자열 값 파싱 */ private function parseValue(string $value): mixed { $trimmed = trim($value); // 따옴표로 감싸진 문자열 if ((str_starts_with($trimmed, "'") && str_ends_with($trimmed, "'")) || (str_starts_with($trimmed, '"') && str_ends_with($trimmed, '"'))) { return substr($trimmed, 1, -1); } // boolean if (strtolower($trimmed) === 'true') { return true; } if (strtolower($trimmed) === 'false') { return false; } // null if (strtolower($trimmed) === 'null') { return null; } // 숫자 if (is_numeric($trimmed)) { return str_contains($trimmed, '.') ? (float) $trimmed : (int) $trimmed; } // JSON 배열/객체 if ((str_starts_with($trimmed, '[') && str_ends_with($trimmed, ']')) || (str_starts_with($trimmed, '{') && str_ends_with($trimmed, '}'))) { $decoded = json_decode($trimmed, true); if (json_last_error() === JSON_ERROR_NONE) { return $decoded; } } return $trimmed; } /** * Truthy 체크 */ private function isTruthy(mixed $value): bool { if ($value === null || $value === false || $value === '' || $value === 0 || $value === '0') { return false; } if (is_array($value) && empty($value)) { return false; } return true; } /** * 결과 생성 */ private function result(bool $passed, string $reason): array { return [ 'passed' => $passed, 'reason' => $reason, 'log' => $this->evaluationLog, ]; } /** * 평가 로그 추가 */ private function log(string $message): void { $this->evaluationLog[] = $message; } /** * 상태 초기화 */ public function reset(): void { $this->stepResults = []; $this->evaluationLog = []; } /** * 스텝 결과 조회 */ public function getStepResults(): array { return $this->stepResults; } }