509 lines
16 KiB
PHP
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;
|
|
}
|
|
}
|