Files
sam-manage/app/Services/FlowTester/ConditionEvaluator.php
2026-02-25 11:45:01 +09:00

509 lines
16 KiB
PHP

<?php
namespace App\Services\FlowTester;
/**
* 조건 표현식 평가기
*
* 플로우 스텝의 condition 필드를 평가하여 실행 여부를 결정합니다.
*
* 지원하는 조건 타입:
* 1. 문자열 표현식: "{{step.field}} == 'value'"
* 2. 비교 객체: {"left": "{{step.field}}", "op": "==", "right": "value"}
* 3. 논리 연산: {"and": [...]} / {"or": [...]} / {"not": {...}}
* 4. 존재 확인: {"exists": "{{step.field}}"} / {"notExists": ...}
* 5. 스텝 결과: {"stepResult": "step_id", "is": "success|failure|skipped"}
* 6. 값 포함: {"left": "{{step.field}}", "op": "in", "right": ["a", "b"]}
*
* 사용 예시:
* - 로그인 성공 시만 실행: {"stepResult": "login", "is": "success"}
* - 관리자만 실행: "{{login.response.role}} == 'admin'"
* - 금액 조건: {"left": "{{order.total}}", "op": ">", "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;
}
}