diff --git a/composer.json b/composer.json index 5989581..50ccb0f 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,11 @@ "require": { "php": "^7.4 || ^8.0", "ext-json": "*", - "psr/log": "^1.1" + "ext-mbstring": "*", + "psr/log": "^1.1", + "symfony/polyfill-php80": "^1.32", + "symfony/yaml": "^5.4", + "symfony/polyfill-php81": "^1.33" }, "require-dev": { "phpunit/phpunit": "^9", diff --git a/composer.lock b/composer.lock index aad6303..ae2682c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d0ed76eebaf1dbe48e451ee0d11b995e", + "content-hash": "2903afdee6032caec39bf340fcd1515a", "packages": [ { "name": "psr/log", @@ -55,6 +55,387 @@ "source": "https://github.com/php-fig/log/tree/1.1.4" }, "time": "2021-05-03T11:20:27+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v2.5.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918", + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "2.5-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:11:13+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/yaml", + "version": "v5.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "a454d47278cc16a5db371fe73ae66a78a633371e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/a454d47278cc16a5db371fe73ae66a78a633371e", + "reference": "a454d47278cc16a5db371fe73ae66a78a633371e", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<5.3" + }, + "require-dev": { + "symfony/console": "^5.3|^6.0" + }, + "suggest": { + "symfony/console": "For validating YAML files using the lint command" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v5.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:11:13+00:00" } ], "packages-dev": [ @@ -1909,7 +2290,8 @@ "prefer-lowest": false, "platform": { "php": "^7.4 || ^8.0", - "ext-json": "*" + "ext-json": "*", + "ext-mbstring": "*" }, "platform-dev": [], "plugin-api-version": "2.2.0" diff --git a/src/Conditions.php b/src/Conditions.php deleted file mode 100644 index 5b1ef14..0000000 --- a/src/Conditions.php +++ /dev/null @@ -1,196 +0,0 @@ - $condition, 'context' => $context]); - // Match all via '*' - if ($condition === '*') { - return true; - } - - // If not array, cannot match - if (!is_array($condition)) { - return false; - } - - // Logical operators - if (isset($condition['and'])) { - $andConditions = self::isSequentialArray($condition['and']) ? $condition['and'] : [$condition['and']]; - foreach ($andConditions as $subCondition) { - if (!self::conditionIsMatched($subCondition, $context, $getRegex)) { - return false; - } - } - return true; - } - if (isset($condition['or'])) { - $orConditions = self::isSequentialArray($condition['or']) ? $condition['or'] : [$condition['or']]; - foreach ($orConditions as $subCondition) { - if (self::conditionIsMatched($subCondition, $context, $getRegex)) { - return true; - } - } - return false; - } - if (isset($condition['not'])) { - $notConditions = self::isSequentialArray($condition['not']) ? $condition['not'] : [$condition['not']]; - if (count($notConditions) === 0) { - return true; - } - foreach ($notConditions as $subCondition) { - if (self::conditionIsMatched($subCondition, $context, $getRegex)) { - return false; - } - } - return true; - } - - $attribute = $condition['attribute'] ?? ''; - $operator = $condition['operator'] ?? ''; - $value = $condition['value'] ?? null; - $regexFlags = $condition['regexFlags'] ?? ''; - - $contextValueFromPath = self::getValueFromContext($context, $attribute); - - if ($operator === 'equals') { - return $contextValueFromPath === $value; - } elseif ($operator === 'notEquals') { - return $contextValueFromPath !== $value; - } elseif ($operator === 'before' || $operator === 'after') { - // date comparisons - $valueInContext = $contextValueFromPath; - - $dateInContext = is_string($valueInContext) ? new \DateTime($valueInContext) : $valueInContext; - $dateInCondition = is_string($value) ? new \DateTime($value) : $value; - - return $operator === 'before' - ? $dateInContext < $dateInCondition - : $dateInContext > $dateInCondition; - } elseif ( - is_array($value) && - (is_string($contextValueFromPath) || is_numeric($contextValueFromPath) || $contextValueFromPath === null) - ) { - // in / notIn (where condition value is an array) - $valueInContext = $contextValueFromPath; - - if ($operator === 'in') { - return in_array($valueInContext, $value); - } elseif ( - $operator === 'notIn' && - self::pathExists($context, $attribute) - ) { - return !in_array($valueInContext, $value); - } - - } elseif (is_string($contextValueFromPath) && is_string($value)) { - // string - $valueInContext = $contextValueFromPath; - - if ($operator === 'contains') { - return strpos($valueInContext, $value) !== false; - } elseif ($operator === 'notContains') { - return strpos($valueInContext, $value) === false; - } elseif ($operator === 'startsWith') { - return strpos($valueInContext, $value) === 0; - } elseif ($operator === 'endsWith') { - return substr($valueInContext, -strlen($value)) === $value; - } elseif ($operator === 'semverEquals') { - return CompareVersions::compare($valueInContext, $value) === 0; - } elseif ($operator === 'semverNotEquals') { - return CompareVersions::compare($valueInContext, $value) !== 0; - } elseif ($operator === 'semverGreaterThan') { - return CompareVersions::compare($valueInContext, $value) === 1; - } elseif ($operator === 'semverGreaterThanOrEquals') { - return CompareVersions::compare($valueInContext, $value) >= 0; - } elseif ($operator === 'semverLessThan') { - return CompareVersions::compare($valueInContext, $value) === -1; - } elseif ($operator === 'semverLessThanOrEquals') { - return CompareVersions::compare($valueInContext, $value) <= 0; - } elseif ($operator === 'matches') { - $regex = $getRegex($value, $regexFlags); - return preg_match($regex, $valueInContext); - } elseif ($operator === 'notMatches') { - $regex = $getRegex($value, $regexFlags); - return !preg_match($regex, $valueInContext); - } - } elseif (is_numeric($contextValueFromPath) && is_numeric($value)) { - // numeric - $valueInContext = $contextValueFromPath; - - if ($operator === 'greaterThan') { - return $valueInContext > $value; - } elseif ($operator === 'greaterThanOrEquals') { - return $valueInContext >= $value; - } elseif ($operator === 'lessThan') { - return $valueInContext < $value; - } elseif ($operator === 'lessThanOrEquals') { - return $valueInContext <= $value; - } - } elseif ($operator === 'exists') { - return self::pathExists($context, $attribute); - } elseif ($operator === 'notExists') { - return !self::pathExists($context, $attribute); - } elseif (is_array($contextValueFromPath) && is_string($value)) { - // includes / notIncludes (where context value is an array) - $valueInContext = $contextValueFromPath; - - if ($operator === 'includes') { - return in_array($value, $valueInContext); - } elseif ($operator === 'notIncludes') { - return !in_array($value, $valueInContext); - } - } - - return false; - } -} diff --git a/src/Datafile/AttributeException.php b/src/Datafile/AttributeException.php new file mode 100644 index 0000000..bfe7357 --- /dev/null +++ b/src/Datafile/AttributeException.php @@ -0,0 +1,20 @@ +>|array{attribute: string, operator: string, value?: mixed, regexFlags?: string}> $conditions + * @return self + */ + public static function createFromMixed($conditions): self + { + if ($conditions === '*') { + return new self(new EveryoneCondition()); + } + + if (is_string($conditions)) { // Unsupported string condition + return new self(new NotCondition(new EveryoneCondition())); + } + + if (is_array($conditions) === false) { + throw new \InvalidArgumentException('Conditions must be array or string'); + } + + $factory = new ConditionFactory(); + + return new self($factory->create($conditions)); + } + + public function __construct(ConditionInterface $expression) + { + $this->expression = $expression; + } + + public function isSatisfiedBy(array $context): bool + { + try { + return $this->expression->isSatisfiedBy($context); + } catch (AttributeException $e) { + return false; + } + } +} diff --git a/src/Datafile/Conditions/AfterCondition.php b/src/Datafile/Conditions/AfterCondition.php new file mode 100644 index 0000000..061a1dc --- /dev/null +++ b/src/Datafile/Conditions/AfterCondition.php @@ -0,0 +1,46 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + try { + if ($valueFromContext === null) { + return false; + } + if ($valueFromContext instanceof DateTimeInterface) { + $contextDate = DateTimeImmutable::createFromFormat( + DateTimeInterface::RFC3339, + $valueFromContext->format(DateTimeInterface::RFC3339) + ); + } else { + $contextDate = new DateTimeImmutable($valueFromContext); + } + + return $contextDate > $this->value; + } catch (Exception $e) { + return false; + } + } +} diff --git a/src/Datafile/Conditions/AndCondition.php b/src/Datafile/Conditions/AndCondition.php new file mode 100644 index 0000000..f618e42 --- /dev/null +++ b/src/Datafile/Conditions/AndCondition.php @@ -0,0 +1,34 @@ + */ + private array $conditions; + + public function __construct(ConditionInterface ...$conditions) + { + $this->conditions = $conditions; + } + + public function isSatisfiedBy(array $context): bool + { + foreach ($this->conditions as $condition) { + try { + if ($condition->isSatisfiedBy($context) === false) { + return false; + } + } catch (AttributeException $e) { + return false; + } + } + + return true; + } +} diff --git a/src/Datafile/Conditions/BeforeCondition.php b/src/Datafile/Conditions/BeforeCondition.php new file mode 100644 index 0000000..118499a --- /dev/null +++ b/src/Datafile/Conditions/BeforeCondition.php @@ -0,0 +1,45 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + try { + if ($valueFromContext === null) { + return false; + } + if ($valueFromContext instanceof DateTimeInterface) { + $contextDate = DateTimeImmutable::createFromFormat( + DateTimeInterface::RFC3339, + $valueFromContext->format(DateTimeInterface::RFC3339) + ); + } else { + $contextDate = new DateTimeImmutable($valueFromContext); + } + + return $contextDate < $this->value; + } catch (\Exception $e) { + return false; + } + } +} diff --git a/src/Datafile/Conditions/CompositeCondition.php b/src/Datafile/Conditions/CompositeCondition.php new file mode 100644 index 0000000..1b7f82f --- /dev/null +++ b/src/Datafile/Conditions/CompositeCondition.php @@ -0,0 +1,38 @@ +map($conditions); + } + + $mappedConditions = array_map(fn ($condition) => $this->map($condition), $conditions); + + if (count($mappedConditions) === 1) { + return $mappedConditions[0]; + } + + return new AndCondition(...$mappedConditions); + } + + /** + * @param array $condition + * @return ConditionInterface + */ + private function map(array $condition): ConditionInterface + { + if (array_key_exists('and', $condition)) { + return $this->createLogicOperator('and', $condition['and']); + } + + if (array_key_exists('or', $condition)) { + return $this->createLogicOperator('or', $condition['or']); + } + + if (array_key_exists('not', $condition)) { + return $this->createLogicOperator('not', $condition['not']); + } + + return $this->createCondition($condition); + } + + private function createCondition(array $condition): ConditionInterface + { + if (!isset($condition['attribute']) || !isset($condition['operator'])) { + throw new InvalidArgumentException('Invalid condition format'); + } + + $attribute = $condition['attribute']; + $value = $condition['value'] ?? null; + + switch ($condition['operator']) { + case 'after': + return new AfterCondition($attribute, new DateTimeImmutable($value)); + case 'before': + return new BeforeCondition($attribute, new DateTimeImmutable($value)); + case 'contains': + return new ContainsCondition($attribute, $value); + case 'notContains': + return new NotCondition(new ContainsCondition($attribute, $value)); + case 'endsWith': + return new EndsWithCondition($attribute, $value); + case 'equals': + return new EqualsCondition($attribute, $value); + case 'notEquals': + return new NotCondition(new EqualsCondition($attribute, $value)); + case 'exists': + return new ExistsCondition($attribute); + case 'notExists': + return new NotCondition(new ExistsCondition($attribute)); + case 'greaterThan': + return new GreaterThanCondition($attribute, $value); + case 'greaterThanOrEquals': + return new GreaterThanOrEqualsCondition($attribute, $value); + case 'includes': + return new IncludesCondition($attribute, $value); + case 'notIncludes': + return new NotCondition(new IncludesCondition($attribute, $value)); + case 'in': + return new InCondition($attribute, $value); + case 'notIn': + return new NotCondition(new InCondition($attribute, $value)); + case 'lessThan': + return new LessThanCondition($attribute, $value); + case 'lessThanOrEquals': + return new LessThanOrEqualsCondition($attribute, $value); + case 'matches': + return new MatchesCondition($attribute, sprintf('/%s/%s', $value, $condition['regexFlags'] ?? '')); + case 'notMatches': + return new NotCondition(new MatchesCondition($attribute, sprintf('/%s/%s', $value, $condition['regexFlags'] ?? ''))); + case 'semverEquals': + return new SemverEqualsCondition($attribute, $value); + case 'semverNotEquals': + return new NotCondition(new SemverEqualsCondition($attribute, $value)); + case 'semverGreaterThan': + return new SemverGreaterThanCondition($attribute, $value); + case 'semverGreaterThanOrEquals': + return new SemverGreaterThanOrEqualsCondition($attribute, $value); + case 'semverLessThan': + return new SemverLessThanCondition($attribute, $value); + case 'semverLessThanOrEquals': + return new SemverLessThanOrEqualsCondition($attribute, $value); + case 'startsWith': + return new StartsWithCondition($attribute, $value); + default: + throw new InvalidArgumentException('Unknown operator: ' . $condition['operator']); + } + } + + private function createLogicOperator(string $operator, array $conditions): ConditionInterface + { + $mappedConditions = array_map(fn ($condition) => $this->map($condition), $conditions); + + switch ($operator) { + case 'and': + return new AndCondition(...$mappedConditions); + case 'or': + return new OrCondition(...$mappedConditions); + case 'not': + if (count($mappedConditions) > 1) { + $mappedConditions = new AndCondition(...$mappedConditions); + } else { + $mappedConditions = $mappedConditions[0]; + } + return new NotCondition($mappedConditions); + default: + throw new InvalidArgumentException('Unknown logical operator: ' . $operator); + } + } +} diff --git a/src/Datafile/Conditions/ConditionInterface.php b/src/Datafile/Conditions/ConditionInterface.php new file mode 100644 index 0000000..05cc430 --- /dev/null +++ b/src/Datafile/Conditions/ConditionInterface.php @@ -0,0 +1,13 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + return str_contains((string) $this->getValueFromContext($context, $this->attribute), $this->value); + } +} diff --git a/src/Datafile/Conditions/ContextLookup.php b/src/Datafile/Conditions/ContextLookup.php new file mode 100644 index 0000000..cc57120 --- /dev/null +++ b/src/Datafile/Conditions/ContextLookup.php @@ -0,0 +1,50 @@ + $allowedTypes + * @param mixed $value + * @throws AttributeException + */ + private function validateType(array $allowedTypes, string $attribute, $value): void + { + if ($value !== null && in_array(gettype($value), $allowedTypes, true) === false) { + throw AttributeException::createForInvalidType($attribute, $allowedTypes, gettype($value)); + } + } +} diff --git a/src/Datafile/Conditions/EndsWithCondition.php b/src/Datafile/Conditions/EndsWithCondition.php new file mode 100644 index 0000000..b5ca739 --- /dev/null +++ b/src/Datafile/Conditions/EndsWithCondition.php @@ -0,0 +1,25 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + return str_ends_with((string) $this->getValueFromContext($context, $this->attribute), $this->value); + } +} diff --git a/src/Datafile/Conditions/EqualsCondition.php b/src/Datafile/Conditions/EqualsCondition.php new file mode 100644 index 0000000..22d38f1 --- /dev/null +++ b/src/Datafile/Conditions/EqualsCondition.php @@ -0,0 +1,27 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + return $this->getValueFromContext($context, $this->attribute) === $this->value; + } +} diff --git a/src/Datafile/Conditions/EveryoneCondition.php b/src/Datafile/Conditions/EveryoneCondition.php new file mode 100644 index 0000000..4e5c5ea --- /dev/null +++ b/src/Datafile/Conditions/EveryoneCondition.php @@ -0,0 +1,14 @@ +attribute = $attribute; + } + + public function isSatisfiedBy(array $context): bool + { + if (strpos($this->attribute, '.') === false) { + return array_key_exists($this->attribute, $context); + } + + $keys = explode('.', $this->attribute); + $current = $context; + + foreach ($keys as $key) { + if (!is_array($current) || !array_key_exists($key, $current)) { + return false; + } + $current = $current[$key]; + } + + return true; + } +} diff --git a/src/Datafile/Conditions/GreaterThanCondition.php b/src/Datafile/Conditions/GreaterThanCondition.php new file mode 100644 index 0000000..66b8903 --- /dev/null +++ b/src/Datafile/Conditions/GreaterThanCondition.php @@ -0,0 +1,36 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + if ($valueFromContext === null) { + return false; + } + + return $valueFromContext > $this->value; + } +} diff --git a/src/Datafile/Conditions/GreaterThanOrEqualsCondition.php b/src/Datafile/Conditions/GreaterThanOrEqualsCondition.php new file mode 100644 index 0000000..e6b70d3 --- /dev/null +++ b/src/Datafile/Conditions/GreaterThanOrEqualsCondition.php @@ -0,0 +1,33 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + return (new GreaterThanCondition($this->attribute, $this->value)) + ->or(new EqualsCondition($this->attribute, $this->value)) + ->isSatisfiedBy($context); + } +} diff --git a/src/Datafile/Conditions/InCondition.php b/src/Datafile/Conditions/InCondition.php new file mode 100644 index 0000000..42973af --- /dev/null +++ b/src/Datafile/Conditions/InCondition.php @@ -0,0 +1,41 @@ + */ + private array $value; + + /** + * @param array $value + */ + public function __construct(string $attribute, array $value) + { + foreach ($value as $item) { + if (is_string($item) === false) { + throw new \InvalidArgumentException('InCondition value must be array of strings'); + } + } + + $this->attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + $this->validateType(self::ALLOWED_TYPES, $this->attribute, $valueFromContext); + + return in_array($valueFromContext, $this->value, true); + } +} diff --git a/src/Datafile/Conditions/IncludesCondition.php b/src/Datafile/Conditions/IncludesCondition.php new file mode 100644 index 0000000..7d436e8 --- /dev/null +++ b/src/Datafile/Conditions/IncludesCondition.php @@ -0,0 +1,30 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + if (is_array($valueFromContext) === false) { + return false; + } + + return in_array($this->value, $valueFromContext,true); + } +} diff --git a/src/Datafile/Conditions/LessThanCondition.php b/src/Datafile/Conditions/LessThanCondition.php new file mode 100644 index 0000000..8e721a8 --- /dev/null +++ b/src/Datafile/Conditions/LessThanCondition.php @@ -0,0 +1,36 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + if ($valueFromContext === null) { + return false; + } + + return $valueFromContext < $this->value; + } +} diff --git a/src/Datafile/Conditions/LessThanOrEqualsCondition.php b/src/Datafile/Conditions/LessThanOrEqualsCondition.php new file mode 100644 index 0000000..3100148 --- /dev/null +++ b/src/Datafile/Conditions/LessThanOrEqualsCondition.php @@ -0,0 +1,33 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + return (new LessThanCondition($this->attribute, $this->value)) + ->or(new EqualsCondition($this->attribute, $this->value)) + ->isSatisfiedBy($context); + } +} diff --git a/src/Datafile/Conditions/MatchesCondition.php b/src/Datafile/Conditions/MatchesCondition.php new file mode 100644 index 0000000..7180b03 --- /dev/null +++ b/src/Datafile/Conditions/MatchesCondition.php @@ -0,0 +1,25 @@ +attribute = $attribute; + $this->regex = $regex; + } + + public function isSatisfiedBy(array $context): bool + { + return preg_match($this->regex, (string) $this->getValueFromContext($context, $this->attribute)) === 1; + } +} diff --git a/src/Datafile/Conditions/NotCondition.php b/src/Datafile/Conditions/NotCondition.php new file mode 100644 index 0000000..752fa4c --- /dev/null +++ b/src/Datafile/Conditions/NotCondition.php @@ -0,0 +1,23 @@ +specification = $specification; + } + + public function isSatisfiedBy(array $context): bool + { + return $this->specification->isSatisfiedBy($context) === false; + } +} diff --git a/src/Datafile/Conditions/OrCondition.php b/src/Datafile/Conditions/OrCondition.php new file mode 100644 index 0000000..76bf709 --- /dev/null +++ b/src/Datafile/Conditions/OrCondition.php @@ -0,0 +1,34 @@ + */ + private array $conditions; + + public function __construct(ConditionInterface ...$conditions) + { + $this->conditions = $conditions; + } + + public function isSatisfiedBy(array $context): bool + { + foreach ($this->conditions as $condition) { + try { + if ($condition->isSatisfiedBy($context) === true) { + return true; + } + } catch (AttributeException $e) { + continue; + } + } + + return false; + } +} diff --git a/src/Datafile/Conditions/SemverEqualsCondition.php b/src/Datafile/Conditions/SemverEqualsCondition.php new file mode 100644 index 0000000..6b29573 --- /dev/null +++ b/src/Datafile/Conditions/SemverEqualsCondition.php @@ -0,0 +1,41 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + if ($valueFromContext === null) { + return false; + } + $comparator = new VersionComparator(); + + try { + return $comparator( + new Semver($valueFromContext), + new Semver($this->value) + ) === 0; + } catch (InvalidArgumentException $e) { + return false; + } + } +} diff --git a/src/Datafile/Conditions/SemverGreaterThanCondition.php b/src/Datafile/Conditions/SemverGreaterThanCondition.php new file mode 100644 index 0000000..4f60e5d --- /dev/null +++ b/src/Datafile/Conditions/SemverGreaterThanCondition.php @@ -0,0 +1,41 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + if ($valueFromContext === null) { + return false; + } + $comparator = new VersionComparator(); + + try { + return $comparator( + new Semver($valueFromContext), + new Semver($this->value) + ) === 1; + } catch (InvalidArgumentException $e) { + return false; + } + } +} diff --git a/src/Datafile/Conditions/SemverGreaterThanOrEqualsCondition.php b/src/Datafile/Conditions/SemverGreaterThanOrEqualsCondition.php new file mode 100644 index 0000000..4f5935f --- /dev/null +++ b/src/Datafile/Conditions/SemverGreaterThanOrEqualsCondition.php @@ -0,0 +1,32 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + return (new SemverGreaterThanCondition($this->attribute, $this->value)) + ->or(new SemverEqualsCondition($this->attribute, $this->value)) + ->isSatisfiedBy($context); + } +} diff --git a/src/Datafile/Conditions/SemverLessThanCondition.php b/src/Datafile/Conditions/SemverLessThanCondition.php new file mode 100644 index 0000000..71c580b --- /dev/null +++ b/src/Datafile/Conditions/SemverLessThanCondition.php @@ -0,0 +1,44 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + if ($valueFromContext === null) { + return false; + } + $comparator = new VersionComparator(); + + try { + return $comparator( + new Semver($valueFromContext), + new Semver($this->value) + ) === -1; + } catch (InvalidArgumentException $e) { + return false; + } + } +} diff --git a/src/Datafile/Conditions/SemverLessThanOrEqualsCondition.php b/src/Datafile/Conditions/SemverLessThanOrEqualsCondition.php new file mode 100644 index 0000000..0695ee1 --- /dev/null +++ b/src/Datafile/Conditions/SemverLessThanOrEqualsCondition.php @@ -0,0 +1,32 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + return (new SemverLessThanCondition($this->attribute, $this->value)) + ->or(new SemverEqualsCondition($this->attribute, $this->value)) + ->isSatisfiedBy($context); + } +} diff --git a/src/Datafile/Conditions/StartsWithCondition.php b/src/Datafile/Conditions/StartsWithCondition.php new file mode 100644 index 0000000..d51503b --- /dev/null +++ b/src/Datafile/Conditions/StartsWithCondition.php @@ -0,0 +1,25 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + return str_starts_with((string) $this->getValueFromContext($context, $this->attribute), $this->value); + } +} diff --git a/src/Datafile/Conditions/VersionComparator.php b/src/Datafile/Conditions/VersionComparator.php new file mode 100644 index 0000000..fa5e40a --- /dev/null +++ b/src/Datafile/Conditions/VersionComparator.php @@ -0,0 +1,75 @@ +getSegments(); + $n2 = $v2->getSegments(); + + // pop off the patch + $p1 = array_pop($n1); + $p2 = array_pop($n2); + + // validate numbers + $r = $this->compareSegments($n1, $n2); + if ($r !== 0) return $r; + + // validate pre-release + if ($p1 && $p2) { + return $this->compareSegments(explode('.', $p1), explode('.', $p2)); + } + + if ($p1 || $p2) { + return $p1 ? -1 : 1; + } + + return 0; + } + + private function isWildcard(string $s): bool + { + return $s === '*' || $s === 'x' || $s === 'X'; + } + + private function forceType($a, $b): array + { + return gettype($a) !== gettype($b) ? [strval($a), strval($b)] : [$a, $b]; + } + + private function tryParse(string $v) + { + $n = (int) $v; + return is_nan($n) ? $v : $n; + } + + private function compareStrings(string $a, string $b): int + { + if ($this->isWildcard($a) || $this->isWildcard($b)) return 0; + + list($ap, $bp) = $this->forceType($this->tryParse($a), $this->tryParse($b)); + + if ($ap > $bp) return 1; + if ($ap < $bp) return -1; + return 0; + } + + private function compareSegments($a, $b): int + { + $maxLength = max(count($a), count($b)); + + for ($i = 0; $i < $maxLength; $i++) { + $r = $this->compareStrings($a[$i] ?? '0', $b[$i] ?? '0'); + if ($r !== 0) return $r; + } + + return 0; + } +} diff --git a/src/Datafile/Content.php b/src/Datafile/Content.php new file mode 100644 index 0000000..4569331 --- /dev/null +++ b/src/Datafile/Content.php @@ -0,0 +1,79 @@ + */ + private array $segments; + + /** + * @throws JsonException + */ + public static function createFromPath(string $path): self + { + if (file_exists($path) === false) { + throw new \InvalidArgumentException("File '$path' not found"); + } + + return self::createFromJson(file_get_contents($path)); + } + + public static function createFromJson(string $json): self + { + return self::createFromArray(json_decode($json, true, 512, JSON_THROW_ON_ERROR)); + } + + /** + * @param array{ + * schemaVersion: string, + * revision: string, + * segments: array + * } $data + */ + public static function createFromArray(array $data): self + { + return new self( + $data['schemaVersion'], + $data['revision'], + Segment::createManyFromArray($data['segments']) + ); + } + + /** + * @param array $segments + */ + public function __construct(string $schemaVersion, string $revision, array $segments) + { + $this->schemaVersion = $schemaVersion; + $this->revision = $revision; + $this->segments = $segments; + } + + public function getSchemaVersion(): string + { + return $this->schemaVersion; + } + + public function getRevision(): string + { + return $this->revision; + } + + /** + * @return array + */ + public function getSegments(): array + { + return $this->segments; + } +} diff --git a/src/Datafile/Segment.php b/src/Datafile/Segment.php new file mode 100644 index 0000000..52d7a73 --- /dev/null +++ b/src/Datafile/Segment.php @@ -0,0 +1,74 @@ +>|string, + * description: string + * }> $segments + * @return array + */ + public static function createManyFromArray(array $segments): array + { + return array_map( + static fn(array $segment): Segment => self::createFromArray($segment), + $segments + ); + } + + /** + * @param array{ + * archived: bool, + * conditions: list>|string, + * description: string + * } $data + */ + public static function createFromArray(array $data): self + { + return new self( + $data['description'] ?? '', + Conditions::createFromMixed($data['conditions']), + $data['archived'] ?? false + ); + } + + public function __construct( + string $description, + Conditions $conditions, + bool $archived = false + ) + { + $this->archived = $archived; + $this->conditions = $conditions; + $this->description = $description; + } + + public function isArchived(): bool + { + return $this->archived; + } + + public function getDescription(): string + { + return $this->description; + } + + public function allConditionsAreMatched(array $context): bool + { + return $this->conditions->isSatisfiedBy($context); + } + +} diff --git a/src/Datafile/Semver.php b/src/Datafile/Semver.php new file mode 100644 index 0000000..2d0ebc1 --- /dev/null +++ b/src/Datafile/Semver.php @@ -0,0 +1,54 @@ +=]*?(\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+))?(?:-([\da-z\-]+(?:\.[\da-z\-]+)*))?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?)?)?$/i'; + + private string $value; + private array $segments; + + /** + * @param mixed $value + * @throws InvalidArgumentException + */ + public static function createFromMixed($value): self + { + if (is_string($value) === false) { + throw new InvalidArgumentException('Invalid argument expected string'); + } + + return new self($value); + } + + /** + * @throws InvalidArgumentException + */ + public function __construct(string $value) + { + if (!preg_match(self::VALIDATION_REGEX, $value, $match)) { + throw new InvalidArgumentException("Invalid argument not valid semver ('$value' received)"); + } + + array_shift($match); + $this->value = $value; + $this->segments = $match; + } + + public function getValue(): string + { + return $this->value; + } + + public function getSegments(): array + { + return $this->segments; + } +} diff --git a/src/DatafileReader.php b/src/DatafileReader.php index efbb01f..d3f22f0 100644 --- a/src/DatafileReader.php +++ b/src/DatafileReader.php @@ -2,6 +2,8 @@ namespace Featurevisor; +use Featurevisor\Datafile\Conditions; +use Featurevisor\Datafile\Segment; use Psr\Log\LoggerInterface; class DatafileReader @@ -35,7 +37,7 @@ public function getSchemaVersion(): string return $this->schemaVersion; } - public function getSegment(string $segmentKey): ?array + public function findSegment(string $segmentKey): ?array { $segment = $this->segments[$segmentKey] ?? null; @@ -93,82 +95,7 @@ public function getRegex(string $regexString, string $regexFlags = ''): string public function allConditionsAreMatched($conditions, array $context): bool { - if (is_string($conditions)) { - if ($conditions === '*') { - return true; - } - // Try to parse as JSON - $parsed = json_decode($conditions, true); - if (json_last_error() === JSON_ERROR_NONE) { - $conditions = $parsed; - } else { - return false; - } - } - - $getRegex = function(string $regexString, string $regexFlags) { - return $this->getRegex($regexString, $regexFlags); - }; - - if (is_array($conditions)) { - // If it's an empty array, always match (true) - if (count($conditions) === 0) { - return true; - } - // Logical operators - if (isset($conditions['and']) && is_array($conditions['and'])) { - foreach ($conditions['and'] as $subCondition) { - if (!$this->allConditionsAreMatched($subCondition, $context)) { - return false; - } - } - return true; - } - if (isset($conditions['or']) && is_array($conditions['or'])) { - foreach ($conditions['or'] as $subCondition) { - if ($this->allConditionsAreMatched($subCondition, $context)) { - return true; - } - } - return false; - } - if (isset($conditions['not']) && is_array($conditions['not'])) { - foreach ($conditions['not'] as $subCondition) { - if ($this->allConditionsAreMatched($subCondition, $context)) { - return false; - } - } - return true; - } - // If it's a plain array, treat as AND (all must match) - if (array_keys($conditions) === range(0, count($conditions) - 1)) { - foreach ($conditions as $subCondition) { - if (!$this->allConditionsAreMatched($subCondition, $context)) { - return false; - } - } - return true; - } - // If it's a single condition (associative array) - if (isset($conditions['attribute'])) { - try { - return Conditions::conditionIsMatched($conditions, $context, $getRegex); - } catch (\Exception $e) { - $this->logger->warning($e->getMessage(), [ - 'exception' => $e, - 'condition' => $conditions, - 'context' => $context, - ]); - return false; - } - } - } - return false; - } - - public function segmentIsMatched(array $segment, array $context): bool - { - return $this->allConditionsAreMatched($segment['conditions'], $context); + return Conditions::createFromMixed($conditions)->isSatisfiedBy($context); } public function allSegmentsAreMatched($groupSegments, array $context): bool @@ -178,8 +105,8 @@ public function allSegmentsAreMatched($groupSegments, array $context): bool } if (is_string($groupSegments)) { - $segment = $this->getSegment($groupSegments); - return $segment ? $this->segmentIsMatched($segment, $context) : false; + $segment = $this->findSegment($groupSegments); + return $segment !== null ? Segment::createFromArray($segment)->allConditionsAreMatched($context) : false; } // Logical operators diff --git a/tests/ConditionsTest.php b/tests/ConditionsTest.php index fc94bd9..26c2c67 100644 --- a/tests/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -3,6 +3,7 @@ namespace Featurevisor\Tests; use DateTime; +use Featurevisor\Datafile\Conditions; use PHPUnit\Framework\TestCase; use Featurevisor\DatafileReader; @@ -291,8 +292,8 @@ public function testNotCondition() { [ 'attribute' => 'browser_version', 'operator' => 'equals', 'value' => '1.0' ], ]]]; self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'browser_version' => '2.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '2.0'])); + self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '2.0'])); self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '1.0'])); } @@ -355,4 +356,22 @@ public function testNestedConditions() { self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'browser_version' => '2.0'])); self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['country' => 'de', 'browser_type' => 'firefox', 'device_type' => 'desktop'])); } + + public function testEuropeConditions() + { + $conditions = Conditions::createFromMixed([ + [ + 'attribute' => 'continent', + 'operator' => 'equals', + 'value' => 'europe', + ], + [ + 'attribute' => 'country', + 'operator' => 'notIn', + 'value' => ['gb'], + ] + ]); + + self::assertFalse($conditions->isSatisfiedBy(['country' => ['foo' => 'bar']])); + } } diff --git a/tests/Datafile/Conditions/AfterConditionTest.php b/tests/Datafile/Conditions/AfterConditionTest.php new file mode 100644 index 0000000..a1843d8 --- /dev/null +++ b/tests/Datafile/Conditions/AfterConditionTest.php @@ -0,0 +1,110 @@ + '2023-01-15', + ]; + + $condition = new AfterCondition('date', new DateTimeImmutable('2023-01-01')); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testAfterConditionWithDateEqual(): void + { + $context = [ + 'date' => '2023-01-01', + ]; + + $condition = new AfterCondition('date', new DateTimeImmutable('2023-01-01')); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testAfterConditionWithDateBefore(): void + { + $context = [ + 'date' => '2022-12-15', + ]; + + $condition = new AfterCondition('date', new DateTimeImmutable('2023-01-01')); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testAfterConditionWithNestedAttributeAfter(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'registrationDate' => '2023-01-15', + ], + ], + ]; + + $condition = new AfterCondition('user.profile.registrationDate', new DateTimeImmutable('2023-01-01')); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testAfterConditionWithNestedAttributeBefore(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'registrationDate' => '2022-12-15', + ], + ], + ]; + + $condition = new AfterCondition('user.profile.registrationDate', new DateTimeImmutable('2023-01-01')); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testAfterConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => '2023-01-15', + ]; + $condition = new AfterCondition('date', new DateTimeImmutable('2023-01-01')); + + $condition->isSatisfiedBy($context); + } + + public function testAfterConditionWithInvalidDateFormat(): void + { + $context = [ + 'date' => 'not-a-date', + ]; + + $condition = new AfterCondition('date', new DateTimeImmutable('2023-01-01')); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testAfterConditionWithDateTimeFormat(): void + { + $context = [ + 'date' => '2023-01-15 12:30:45', + ]; + + $condition = new AfterCondition('date', new DateTimeImmutable('2023-01-01 00:00:00')); + + self::assertTrue($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/AndConditionTest.php b/tests/Datafile/Conditions/AndConditionTest.php new file mode 100644 index 0000000..437c607 --- /dev/null +++ b/tests/Datafile/Conditions/AndConditionTest.php @@ -0,0 +1,108 @@ + 'us', + 'age' => 25, + ]; + + $condition = new AndCondition( + new EqualsCondition('country', 'us'), + new GreaterThanCondition('age', 21) + ); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testAndConditionWithOneConditionNotSatisfied(): void + { + $context = [ + 'country' => 'ca', + 'age' => 25, + ]; + + $condition = new AndCondition( + new EqualsCondition('country', 'us'), + new GreaterThanCondition('age', 21) + ); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testAndConditionWithAllConditionsNotSatisfied(): void + { + $context = [ + 'country' => 'ca', + 'age' => 18, + ]; + + $condition = new AndCondition( + new EqualsCondition('country', 'us'), + new GreaterThanCondition('age', 21) + ); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testAndConditionWithNestedAndCondition(): void + { + $context = [ + 'country' => 'us', + 'age' => 25, + 'device' => 'iPhone', + ]; + + $condition = new AndCondition( + new AndCondition( + new EqualsCondition('country', 'us'), + new GreaterThanCondition('age', 21) + ), + new EqualsCondition('device', 'iPhone') + ); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testAndConditionWithNestedAndConditionNotSatisfied(): void + { + $context = [ + 'country' => 'us', + 'age' => 18, + 'device' => 'iPhone', + ]; + + $condition = new AndCondition( + new AndCondition( + new EqualsCondition('country', 'us'), + new GreaterThanCondition('age', 21) + ), + new EqualsCondition('device', 'iPhone') + ); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testAndConditionWithNoConditions(): void + { + $context = [ + 'country' => 'us', + 'age' => 25, + ]; + + $condition = new AndCondition(); + + self::assertTrue($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/BeforeConditionTest.php b/tests/Datafile/Conditions/BeforeConditionTest.php new file mode 100644 index 0000000..875d315 --- /dev/null +++ b/tests/Datafile/Conditions/BeforeConditionTest.php @@ -0,0 +1,110 @@ + '2022-12-15', + ]; + + $condition = new BeforeCondition('date', new DateTimeImmutable('2023-01-01')); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testBeforeConditionWithDateEqual(): void + { + $context = [ + 'date' => '2023-01-01', + ]; + + $condition = new BeforeCondition('date', new DateTimeImmutable('2023-01-01')); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testBeforeConditionWithDateAfter(): void + { + $context = [ + 'date' => '2023-01-15', + ]; + + $condition = new BeforeCondition('date', new DateTimeImmutable('2023-01-01')); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testBeforeConditionWithNestedAttributeBefore(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'registrationDate' => '2022-12-15', + ], + ], + ]; + + $condition = new BeforeCondition('user.profile.registrationDate', new DateTimeImmutable('2023-01-01')); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testBeforeConditionWithNestedAttributeAfter(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'registrationDate' => '2023-01-15', + ], + ], + ]; + + $condition = new BeforeCondition('user.profile.registrationDate', new DateTimeImmutable('2023-01-01')); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testBeforeConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => '2022-12-15', + ]; + $condition = new BeforeCondition('date', new DateTimeImmutable('2023-01-01')); + + $condition->isSatisfiedBy($context); + } + + public function testBeforeConditionWithInvalidDateFormat(): void + { + $context = [ + 'date' => 'not-a-date', + ]; + + $condition = new BeforeCondition('date', new DateTimeImmutable('2023-01-01')); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testBeforeConditionWithDateTimeFormat(): void + { + $context = [ + 'date' => '2022-12-15 12:30:45', + ]; + + $condition = new BeforeCondition('date', new DateTimeImmutable('2023-01-01 00:00:00')); + + self::assertTrue($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/ContainsConditionTest.php b/tests/Datafile/Conditions/ContainsConditionTest.php new file mode 100644 index 0000000..171fd07 --- /dev/null +++ b/tests/Datafile/Conditions/ContainsConditionTest.php @@ -0,0 +1,98 @@ + 'iPhone 12', + ]; + + $condition = new ContainsCondition('device', 'iPhone'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testContainsConditionWithSimpleAttributeNotContaining(): void + { + $context = [ + 'device' => 'Android', + ]; + + $condition = new ContainsCondition('device', 'iPhone'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testContainsConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => 'iPhone 12', + ]; + $condition = new ContainsCondition('device', 'iPhone'); + + $condition->isSatisfiedBy($context); + } + + public function testContainsConditionWithNestedAttributeContaining(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'device' => 'iPhone 12', + ], + ], + ]; + + $condition = new ContainsCondition('user.profile.device', 'iPhone'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testContainsConditionWithNestedAttributeNotContaining(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'device' => 'Android', + ], + ], + ]; + + $condition = new ContainsCondition('user.profile.device', 'iPhone'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testContainsConditionWithEmptyString(): void + { + $context = [ + 'device' => 'iPhone 12', + ]; + + $condition = new ContainsCondition('device', ''); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testContainsConditionWithCaseSensitivity(): void + { + $context = [ + 'device' => 'iPhone 12', + ]; + + $condition = new ContainsCondition('device', 'iphone'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/EndsWithConditionTest.php b/tests/Datafile/Conditions/EndsWithConditionTest.php new file mode 100644 index 0000000..d7d1931 --- /dev/null +++ b/tests/Datafile/Conditions/EndsWithConditionTest.php @@ -0,0 +1,98 @@ + 'My iPhone', + ]; + + $condition = new EndsWithCondition('device', 'iPhone'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testEndsWithConditionWithSimpleAttributeNotEndingWith(): void + { + $context = [ + 'device' => 'iPhone 12', + ]; + + $condition = new EndsWithCondition('device', 'iPhone'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testEndsWithConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => 'My iPhone', + ]; + $condition = new EndsWithCondition('device', 'iPhone'); + + $condition->isSatisfiedBy($context); + } + + public function testEndsWithConditionWithNestedAttributeEndingWith(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'device' => 'My iPhone', + ], + ], + ]; + + $condition = new EndsWithCondition('user.profile.device', 'iPhone'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testEndsWithConditionWithNestedAttributeNotEndingWith(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'device' => 'iPhone 12', + ], + ], + ]; + + $condition = new EndsWithCondition('user.profile.device', 'iPhone'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testEndsWithConditionWithEmptyString(): void + { + $context = [ + 'device' => 'iPhone 12', + ]; + + $condition = new EndsWithCondition('device', ''); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testEndsWithConditionWithCaseSensitivity(): void + { + $context = [ + 'device' => 'My iPhone', + ]; + + $condition = new EndsWithCondition('device', 'iphone'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/EqualsConditionTest.php b/tests/Datafile/Conditions/EqualsConditionTest.php new file mode 100644 index 0000000..cf1c15a --- /dev/null +++ b/tests/Datafile/Conditions/EqualsConditionTest.php @@ -0,0 +1,103 @@ + 'us', + ]; + + $condition = new EqualsCondition('country', 'us'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testEqualsConditionWithSimpleAttributeNotMatching(): void + { + $context = [ + 'country' => 'ca', + ]; + + $condition = new EqualsCondition('country', 'us'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testEqualsConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => 'value', + ]; + $condition = new EqualsCondition('country', 'us'); + + $condition->isSatisfiedBy($context); + } + + public function testEqualsConditionWithNestedAttributeMatching(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'country' => 'us', + ], + ], + ]; + + $condition = new EqualsCondition('user.profile.country', 'us'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testEqualsConditionWithNestedAttributeNotMatching(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'country' => 'ca', + ], + ], + ]; + + $condition = new EqualsCondition('user.profile.country', 'us'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testEqualsConditionWithMissingNestedAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'user' => [ + 'profile' => [ + 'name' => 'John', + ], + ], + ]; + $condition = new EqualsCondition('user.profile.country', 'us'); + + $condition->isSatisfiedBy($context); + } + + public function testEqualsConditionWithDifferentTypes(): void + { + $context = [ + 'age' => '25', // string + ]; + + $condition = new EqualsCondition('age', 25); // integer + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/EveryoneConditionTest.php b/tests/Datafile/Conditions/EveryoneConditionTest.php new file mode 100644 index 0000000..f942ba8 --- /dev/null +++ b/tests/Datafile/Conditions/EveryoneConditionTest.php @@ -0,0 +1,35 @@ +isSatisfiedBy([])); + + // Context with some attributes + self::assertTrue($condition->isSatisfiedBy([ + 'country' => 'us', + 'age' => 25, + ])); + + // Context with nested attributes + self::assertTrue($condition->isSatisfiedBy([ + 'user' => [ + 'profile' => [ + 'country' => 'us', + 'age' => 25, + ], + ], + ])); + } +} diff --git a/tests/Datafile/Conditions/ExistsConditionTest.php b/tests/Datafile/Conditions/ExistsConditionTest.php new file mode 100644 index 0000000..31da9c5 --- /dev/null +++ b/tests/Datafile/Conditions/ExistsConditionTest.php @@ -0,0 +1,76 @@ + 'value', + ]; + + $condition = new ExistsCondition('attribute'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testExistsConditionWithMissingAttribute(): void + { + $context = [ + 'other_attribute' => 'value', + ]; + + $condition = new ExistsCondition('attribute'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testExistsConditionWithNestedAttribute(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 25, + ], + ], + ]; + + $condition = new ExistsCondition('user.profile.age'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testExistsConditionWithMissingNestedAttribute(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'name' => 'John', + ], + ], + ]; + + $condition = new ExistsCondition('user.profile.age'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testExistsConditionWithPartiallyMissingNestedAttribute(): void + { + $context = [ + 'user' => [ + 'name' => 'John', + ], + ]; + + $condition = new ExistsCondition('user.profile.age'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/GreaterThanConditionTest.php b/tests/Datafile/Conditions/GreaterThanConditionTest.php new file mode 100644 index 0000000..3f97809 --- /dev/null +++ b/tests/Datafile/Conditions/GreaterThanConditionTest.php @@ -0,0 +1,121 @@ + 25, + ]; + + $condition = new GreaterThanCondition('age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanConditionWithSimpleAttributeEqual(): void + { + $context = [ + 'age' => 21, + ]; + + $condition = new GreaterThanCondition('age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanConditionWithSimpleAttributeLess(): void + { + $context = [ + 'age' => 18, + ]; + + $condition = new GreaterThanCondition('age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => 25, + ]; + $condition = new GreaterThanCondition('age', 21); + + $condition->isSatisfiedBy($context); + } + + public function testGreaterThanConditionWithNestedAttributeGreater(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 25, + ], + ], + ]; + + $condition = new GreaterThanCondition('user.profile.age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanConditionWithNestedAttributeEqual(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 21, + ], + ], + ]; + + $condition = new GreaterThanCondition('user.profile.age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanConditionWithNestedAttributeLess(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 18, + ], + ], + ]; + + $condition = new GreaterThanCondition('user.profile.age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanConditionWithFloatValues(): void + { + $context = [ + 'score' => 9.5, + ]; + + $condition = new GreaterThanCondition('score', 9.0); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanConditionWithInvalidValueType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('GreaterThanCondition value must be float or integer'); + + new GreaterThanCondition('age', 'not_a_number'); + } +} diff --git a/tests/Datafile/Conditions/GreaterThanOrEqualsConditionTest.php b/tests/Datafile/Conditions/GreaterThanOrEqualsConditionTest.php new file mode 100644 index 0000000..70b7332 --- /dev/null +++ b/tests/Datafile/Conditions/GreaterThanOrEqualsConditionTest.php @@ -0,0 +1,131 @@ + 25, + ]; + + $condition = new GreaterThanOrEqualsCondition('age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanOrEqualsConditionWithSimpleAttributeEqual(): void + { + $context = [ + 'age' => 21, + ]; + + $condition = new GreaterThanOrEqualsCondition('age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanOrEqualsConditionWithSimpleAttributeLess(): void + { + $context = [ + 'age' => 18, + ]; + + $condition = new GreaterThanOrEqualsCondition('age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanOrEqualsConditionWithMissingAttribute(): void + { + $context = [ + 'other_attribute' => 25, + ]; + + $condition = new GreaterThanOrEqualsCondition('age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanOrEqualsConditionWithNestedAttributeGreater(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 25, + ], + ], + ]; + + $condition = new GreaterThanOrEqualsCondition('user.profile.age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanOrEqualsConditionWithNestedAttributeEqual(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 21, + ], + ], + ]; + + $condition = new GreaterThanOrEqualsCondition('user.profile.age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanOrEqualsConditionWithNestedAttributeLess(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 18, + ], + ], + ]; + + $condition = new GreaterThanOrEqualsCondition('user.profile.age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanOrEqualsConditionWithFloatValues(): void + { + $context = [ + 'score' => 9.0, + ]; + + $condition = new GreaterThanOrEqualsCondition('score', 9.0); + + self::assertTrue($condition->isSatisfiedBy($context)); + + $context = [ + 'score' => 9.5, + ]; + + self::assertTrue($condition->isSatisfiedBy($context)); + + $context = [ + 'score' => 8.5, + ]; + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testGreaterThanOrEqualsConditionWithInvalidValueType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('GreaterThanOrEqualCondition value must be float or integer'); + + new GreaterThanOrEqualsCondition('age', 'not_a_number'); + } +} diff --git a/tests/Datafile/Conditions/InConditionTest.php b/tests/Datafile/Conditions/InConditionTest.php new file mode 100644 index 0000000..ee0e8d1 --- /dev/null +++ b/tests/Datafile/Conditions/InConditionTest.php @@ -0,0 +1,126 @@ + 'us', + ]; + + $condition = new InCondition('country', ['us', 'ca', 'uk']); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testInConditionWithSimpleAttributeNotInArray(): void + { + $context = [ + 'country' => 'fr', + ]; + + $condition = new InCondition('country', ['us', 'ca', 'uk']); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testInConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => 'us', + ]; + $condition = new InCondition('country', ['us', 'ca', 'uk']); + + $condition->isSatisfiedBy($context); + } + + public function testInConditionWithNestedAttributeInArray(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'country' => 'us', + ], + ], + ]; + + $condition = new InCondition('user.profile.country', ['us', 'ca', 'uk']); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testInConditionWithNestedAttributeNotInArray(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'country' => 'fr', + ], + ], + ]; + + $condition = new InCondition('user.profile.country', ['us', 'ca', 'uk']); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testInConditionWithEmptyArray(): void + { + $context = [ + 'country' => 'us', + ]; + + $condition = new InCondition('country', []); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testInConditionWithStrictComparison(): void + { + $context = [ + 'value' => '42', + ]; + + $condition = new InCondition('value', ['42', '43']); + + self::assertTrue($condition->isSatisfiedBy($context)); + + // This should be false because of strict comparison (string vs integer) + $context = [ + 'value' => 42, + ]; + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testInConditionWithInvalidValueType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('InCondition value must be array of strings'); + + new InCondition('country', ['us', 42]); + } + + public function testNotInConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'country' => 'us', + ]; + $condition = new NotCondition(new InCondition('continent', ['europe'])); + + $condition->isSatisfiedBy($context); + } +} diff --git a/tests/Datafile/Conditions/IncludesConditionTest.php b/tests/Datafile/Conditions/IncludesConditionTest.php new file mode 100644 index 0000000..746e107 --- /dev/null +++ b/tests/Datafile/Conditions/IncludesConditionTest.php @@ -0,0 +1,116 @@ + ['us', 'ca', 'uk'], + ]; + + $condition = new IncludesCondition('countries', 'us'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testIncludesConditionWithArrayNotIncludingValue(): void + { + $context = [ + 'countries' => ['ca', 'uk', 'fr'], + ]; + + $condition = new IncludesCondition('countries', 'us'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testIncludesConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => ['us', 'ca', 'uk'], + ]; + $condition = new IncludesCondition('countries', 'us'); + + $condition->isSatisfiedBy($context); + } + + public function testIncludesConditionWithNestedArrayIncludingValue(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'countries' => ['us', 'ca', 'uk'], + ], + ], + ]; + + $condition = new IncludesCondition('user.profile.countries', 'us'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testIncludesConditionWithNestedArrayNotIncludingValue(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'countries' => ['ca', 'uk', 'fr'], + ], + ], + ]; + + $condition = new IncludesCondition('user.profile.countries', 'us'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testIncludesConditionWithEmptyArray(): void + { + $context = [ + 'countries' => [], + ]; + + $condition = new IncludesCondition('countries', 'us'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testIncludesConditionWithNonArrayValue(): void + { + $context = [ + 'country' => 'us', + ]; + + $condition = new IncludesCondition('country', 'us'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testIncludesConditionWithStrictComparison(): void + { + $context = [ + 'values' => ['42', '43'], + ]; + + $condition = new IncludesCondition('values', '42'); + + self::assertTrue($condition->isSatisfiedBy($context)); + + // This should be false because of strict comparison (string vs integer) + $context = [ + 'values' => [42, 43], + ]; + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/LessThanConditionTest.php b/tests/Datafile/Conditions/LessThanConditionTest.php new file mode 100644 index 0000000..9e9d191 --- /dev/null +++ b/tests/Datafile/Conditions/LessThanConditionTest.php @@ -0,0 +1,121 @@ + 18, + ]; + + $condition = new LessThanCondition('age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testLessThanConditionWithSimpleAttributeEqual(): void + { + $context = [ + 'age' => 21, + ]; + + $condition = new LessThanCondition('age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testLessThanConditionWithSimpleAttributeGreater(): void + { + $context = [ + 'age' => 25, + ]; + + $condition = new LessThanCondition('age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testLessThanConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => 18, + ]; + $condition = new LessThanCondition('age', 21); + + $condition->isSatisfiedBy($context); + } + + public function testLessThanConditionWithNestedAttributeLess(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 18, + ], + ], + ]; + + $condition = new LessThanCondition('user.profile.age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testLessThanConditionWithNestedAttributeEqual(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 21, + ], + ], + ]; + + $condition = new LessThanCondition('user.profile.age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testLessThanConditionWithNestedAttributeGreater(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 25, + ], + ], + ]; + + $condition = new LessThanCondition('user.profile.age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testLessThanConditionWithFloatValues(): void + { + $context = [ + 'score' => 8.5, + ]; + + $condition = new LessThanCondition('score', 9.0); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testLessThanConditionWithInvalidValueType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('LessThanCondition value must be float or integer'); + + new LessThanCondition('age', 'not_a_number'); + } +} diff --git a/tests/Datafile/Conditions/LessThanOrEqualsConditionTest.php b/tests/Datafile/Conditions/LessThanOrEqualsConditionTest.php new file mode 100644 index 0000000..acde26b --- /dev/null +++ b/tests/Datafile/Conditions/LessThanOrEqualsConditionTest.php @@ -0,0 +1,131 @@ + 18, + ]; + + $condition = new LessThanOrEqualsCondition('age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testLessThanOrEqualsConditionWithSimpleAttributeEqual(): void + { + $context = [ + 'age' => 21, + ]; + + $condition = new LessThanOrEqualsCondition('age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testLessThanOrEqualsConditionWithSimpleAttributeGreater(): void + { + $context = [ + 'age' => 25, + ]; + + $condition = new LessThanOrEqualsCondition('age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testLessThanOrEqualsConditionWithMissingAttribute(): void + { + $context = [ + 'other_attribute' => 18, + ]; + + $condition = new LessThanOrEqualsCondition('age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testLessThanOrEqualsConditionWithNestedAttributeLess(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 18, + ], + ], + ]; + + $condition = new LessThanOrEqualsCondition('user.profile.age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testLessThanOrEqualsConditionWithNestedAttributeEqual(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 21, + ], + ], + ]; + + $condition = new LessThanOrEqualsCondition('user.profile.age', 21); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testLessThanOrEqualsConditionWithNestedAttributeGreater(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'age' => 25, + ], + ], + ]; + + $condition = new LessThanOrEqualsCondition('user.profile.age', 21); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testLessThanOrEqualsConditionWithFloatValues(): void + { + $context = [ + 'score' => 8.5, + ]; + + $condition = new LessThanOrEqualsCondition('score', 9.0); + + self::assertTrue($condition->isSatisfiedBy($context)); + + $context = [ + 'score' => 9.0, + ]; + + self::assertTrue($condition->isSatisfiedBy($context)); + + $context = [ + 'score' => 9.5, + ]; + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testLessThanOrEqualsConditionWithInvalidValueType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('LessThanOrEqualsCondition value must be float or integer'); + + new LessThanOrEqualsCondition('age', 'not_a_number'); + } +} diff --git a/tests/Datafile/Conditions/MatchesConditionTest.php b/tests/Datafile/Conditions/MatchesConditionTest.php new file mode 100644 index 0000000..774657b --- /dev/null +++ b/tests/Datafile/Conditions/MatchesConditionTest.php @@ -0,0 +1,98 @@ + 'user@example.com', + ]; + + $condition = new MatchesCondition('email', '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testMatchesConditionWithSimpleAttributeNotMatching(): void + { + $context = [ + 'email' => 'invalid-email', + ]; + + $condition = new MatchesCondition('email', '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testMatchesConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => 'user@example.com', + ]; + $condition = new MatchesCondition('email', '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'); + + $condition->isSatisfiedBy($context); + } + + public function testMatchesConditionWithNestedAttributeMatching(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'email' => 'user@example.com', + ], + ], + ]; + + $condition = new MatchesCondition('user.profile.email', '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testMatchesConditionWithNestedAttributeNotMatching(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'email' => 'invalid-email', + ], + ], + ]; + + $condition = new MatchesCondition('user.profile.email', '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testMatchesConditionWithSimpleRegex(): void + { + $context = [ + 'zipcode' => '12345', + ]; + + $condition = new MatchesCondition('zipcode', '/^\d{5}$/'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testMatchesConditionWithCaseInsensitiveFlag(): void + { + $context = [ + 'text' => 'Hello World', + ]; + + $condition = new MatchesCondition('text', '/hello/i'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/NotConditionTest.php b/tests/Datafile/Conditions/NotConditionTest.php new file mode 100644 index 0000000..c0ae1db --- /dev/null +++ b/tests/Datafile/Conditions/NotConditionTest.php @@ -0,0 +1,82 @@ + 'us', + ]; + + $condition = new NotCondition( + new EqualsCondition('country', 'us') + ); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testNotConditionWithUnsatisfiedCondition(): void + { + $context = [ + 'country' => 'ca', + ]; + + $condition = new NotCondition( + new EqualsCondition('country', 'us') + ); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testNotConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => 'value', + ]; + $condition = new NotCondition( + new EqualsCondition('country', 'us') + ); + + $condition->isSatisfiedBy($context); + } + + public function testNotConditionWithNestedCondition(): void + { + $context = [ + 'age' => 18, + ]; + + $condition = new NotCondition( + new GreaterThanCondition('age', 21) + ); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testDoubleNotCondition(): void + { + $context = [ + 'country' => 'us', + ]; + + $condition = new NotCondition( + new NotCondition( + new EqualsCondition('country', 'us') + ) + ); + + self::assertTrue($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/OrConditionTest.php b/tests/Datafile/Conditions/OrConditionTest.php new file mode 100644 index 0000000..1034a42 --- /dev/null +++ b/tests/Datafile/Conditions/OrConditionTest.php @@ -0,0 +1,123 @@ + 'us', + 'age' => 25, + ]; + + $condition = new OrCondition( + new EqualsCondition('country', 'us'), + new GreaterThanCondition('age', 21) + ); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testOrConditionWithOneConditionSatisfied(): void + { + $context = [ + 'country' => 'ca', + 'age' => 25, + ]; + + $condition = new OrCondition( + new EqualsCondition('country', 'us'), + new GreaterThanCondition('age', 21) + ); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testOrConditionWithAnotherConditionSatisfied(): void + { + $context = [ + 'country' => 'us', + 'age' => 18, + ]; + + $condition = new OrCondition( + new EqualsCondition('country', 'us'), + new GreaterThanCondition('age', 21) + ); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testOrConditionWithNoConditionsSatisfied(): void + { + $context = [ + 'country' => 'ca', + 'age' => 18, + ]; + + $condition = new OrCondition( + new EqualsCondition('country', 'us'), + new GreaterThanCondition('age', 21) + ); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testOrConditionWithNestedOrCondition(): void + { + $context = [ + 'country' => 'ca', + 'age' => 18, + 'device' => 'iPhone', + ]; + + $condition = new OrCondition( + new OrCondition( + new EqualsCondition('country', 'us'), + new GreaterThanCondition('age', 21) + ), + new EqualsCondition('device', 'iPhone') + ); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testOrConditionWithNestedOrConditionNotSatisfied(): void + { + $context = [ + 'country' => 'ca', + 'age' => 18, + 'device' => 'Android', + ]; + + $condition = new OrCondition( + new OrCondition( + new EqualsCondition('country', 'us'), + new GreaterThanCondition('age', 21) + ), + new EqualsCondition('device', 'iPhone') + ); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testOrConditionWithNoConditions(): void + { + $context = [ + 'country' => 'us', + 'age' => 25, + ]; + + $condition = new OrCondition(); + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/SemverEqualsConditionTest.php b/tests/Datafile/Conditions/SemverEqualsConditionTest.php new file mode 100644 index 0000000..9ecf32a --- /dev/null +++ b/tests/Datafile/Conditions/SemverEqualsConditionTest.php @@ -0,0 +1,119 @@ + '1.2.3', + ]; + + $condition = new SemverEqualsCondition('version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverEqualsConditionWithDifferentVersions(): void + { + $context = [ + 'version' => '1.2.4', + ]; + + $condition = new SemverEqualsCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverEqualsConditionWithDifferentMajorVersions(): void + { + $context = [ + 'version' => '2.0.0', + ]; + + $condition = new SemverEqualsCondition('version', '1.0.0'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverEqualsConditionWithDifferentMinorVersions(): void + { + $context = [ + 'version' => '1.3.0', + ]; + + $condition = new SemverEqualsCondition('version', '1.2.0'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverEqualsConditionWithDifferentPatchVersions(): void + { + $context = [ + 'version' => '1.2.3', + ]; + + $condition = new SemverEqualsCondition('version', '1.2.4'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverEqualsConditionWithNestedAttributeEqual(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.3', + ], + ], + ]; + + $condition = new SemverEqualsCondition('app.info.version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverEqualsConditionWithNestedAttributeNotEqual(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.4', + ], + ], + ]; + + $condition = new SemverEqualsCondition('app.info.version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverEqualsConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => '1.2.3', + ]; + $condition = new SemverEqualsCondition('version', '1.2.3'); + + $condition->isSatisfiedBy($context); + } + + public function testSemverEqualsConditionWithInvalidVersionFormat(): void + { + $context = [ + 'version' => 'not-a-version', + ]; + $condition = new SemverEqualsCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/SemverGreaterThanConditionTest.php b/tests/Datafile/Conditions/SemverGreaterThanConditionTest.php new file mode 100644 index 0000000..815eab3 --- /dev/null +++ b/tests/Datafile/Conditions/SemverGreaterThanConditionTest.php @@ -0,0 +1,131 @@ + '1.2.4', + ]; + + $condition = new SemverGreaterThanCondition('version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanConditionWithEqualVersion(): void + { + $context = [ + 'version' => '1.2.3', + ]; + + $condition = new SemverGreaterThanCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanConditionWithLesserVersion(): void + { + $context = [ + 'version' => '1.2.2', + ]; + + $condition = new SemverGreaterThanCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanConditionWithGreaterMajorVersion(): void + { + $context = [ + 'version' => '2.0.0', + ]; + + $condition = new SemverGreaterThanCondition('version', '1.0.0'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanConditionWithGreaterMinorVersion(): void + { + $context = [ + 'version' => '1.3.0', + ]; + + $condition = new SemverGreaterThanCondition('version', '1.2.0'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanConditionWithGreaterPatchVersion(): void + { + $context = [ + 'version' => '1.2.4', + ]; + + $condition = new SemverGreaterThanCondition('version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanConditionWithNestedAttributeGreater(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.4', + ], + ], + ]; + + $condition = new SemverGreaterThanCondition('app.info.version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanConditionWithNestedAttributeLesser(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.2', + ], + ], + ]; + + $condition = new SemverGreaterThanCondition('app.info.version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => '1.2.4', + ]; + $condition = new SemverGreaterThanCondition('version', '1.2.3'); + + $condition->isSatisfiedBy($context); + } + + public function testSemverGreaterThanConditionWithInvalidVersionFormat(): void + { + $context = [ + 'version' => 'not-a-version', + ]; + + $condition = new SemverGreaterThanCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/SemverGreaterThanOrEqualsConditionTest.php b/tests/Datafile/Conditions/SemverGreaterThanOrEqualsConditionTest.php new file mode 100644 index 0000000..7dd0df8 --- /dev/null +++ b/tests/Datafile/Conditions/SemverGreaterThanOrEqualsConditionTest.php @@ -0,0 +1,144 @@ + '1.2.4', + ]; + + $condition = new SemverGreaterThanOrEqualsCondition('version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanOrEqualsConditionWithEqualVersion(): void + { + $context = [ + 'version' => '1.2.3', + ]; + + $condition = new SemverGreaterThanOrEqualsCondition('version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanOrEqualsConditionWithLesserVersion(): void + { + $context = [ + 'version' => '1.2.2', + ]; + + $condition = new SemverGreaterThanOrEqualsCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanOrEqualsConditionWithGreaterMajorVersion(): void + { + $context = [ + 'version' => '2.0.0', + ]; + + $condition = new SemverGreaterThanOrEqualsCondition('version', '1.0.0'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanOrEqualsConditionWithGreaterMinorVersion(): void + { + $context = [ + 'version' => '1.3.0', + ]; + + $condition = new SemverGreaterThanOrEqualsCondition('version', '1.2.0'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanOrEqualsConditionWithGreaterPatchVersion(): void + { + $context = [ + 'version' => '1.2.4', + ]; + + $condition = new SemverGreaterThanOrEqualsCondition('version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanOrEqualsConditionWithNestedAttributeGreater(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.4', + ], + ], + ]; + + $condition = new SemverGreaterThanOrEqualsCondition('app.info.version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanOrEqualsConditionWithNestedAttributeEqual(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.3', + ], + ], + ]; + + $condition = new SemverGreaterThanOrEqualsCondition('app.info.version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanOrEqualsConditionWithNestedAttributeLesser(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.2', + ], + ], + ]; + + $condition = new SemverGreaterThanOrEqualsCondition('app.info.version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanOrEqualsConditionWithMissingAttribute(): void + { + $context = [ + 'other_attribute' => '1.2.4', + ]; + + $condition = new SemverGreaterThanOrEqualsCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanOrEqualsConditionWithInvalidVersionFormat(): void + { + $context = [ + 'version' => 'not-a-version', + ]; + + $condition = new SemverGreaterThanOrEqualsCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/SemverLessThanConditionTest.php b/tests/Datafile/Conditions/SemverLessThanConditionTest.php new file mode 100644 index 0000000..0dbc2dd --- /dev/null +++ b/tests/Datafile/Conditions/SemverLessThanConditionTest.php @@ -0,0 +1,131 @@ + '1.2.2', + ]; + + $condition = new SemverLessThanCondition('version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanConditionWithEqualVersion(): void + { + $context = [ + 'version' => '1.2.3', + ]; + + $condition = new SemverLessThanCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanConditionWithGreaterVersion(): void + { + $context = [ + 'version' => '1.2.4', + ]; + + $condition = new SemverLessThanCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanConditionWithLesserMajorVersion(): void + { + $context = [ + 'version' => '1.0.0', + ]; + + $condition = new SemverLessThanCondition('version', '2.0.0'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanConditionWithLesserMinorVersion(): void + { + $context = [ + 'version' => '1.1.0', + ]; + + $condition = new SemverLessThanCondition('version', '1.2.0'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanConditionWithLesserPatchVersion(): void + { + $context = [ + 'version' => '1.2.2', + ]; + + $condition = new SemverLessThanCondition('version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanConditionWithNestedAttributeLesser(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.2', + ], + ], + ]; + + $condition = new SemverLessThanCondition('app.info.version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanConditionWithNestedAttributeGreater(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.4', + ], + ], + ]; + + $condition = new SemverLessThanCondition('app.info.version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => '1.2.2', + ]; + $condition = new SemverLessThanCondition('version', '1.2.3'); + + $condition->isSatisfiedBy($context); + } + + public function testSemverLessThanConditionWithInvalidVersionFormat(): void + { + $context = [ + 'version' => 'not-a-version', + ]; + + $condition = new SemverLessThanCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/SemverLessThanOrEqualsConditionTest.php b/tests/Datafile/Conditions/SemverLessThanOrEqualsConditionTest.php new file mode 100644 index 0000000..98a7669 --- /dev/null +++ b/tests/Datafile/Conditions/SemverLessThanOrEqualsConditionTest.php @@ -0,0 +1,144 @@ + '1.2.2', + ]; + + $condition = new SemverLessThanOrEqualsCondition('version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanOrEqualsConditionWithEqualVersion(): void + { + $context = [ + 'version' => '1.2.3', + ]; + + $condition = new SemverLessThanOrEqualsCondition('version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanOrEqualsConditionWithGreaterVersion(): void + { + $context = [ + 'version' => '1.2.4', + ]; + + $condition = new SemverLessThanOrEqualsCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanOrEqualsConditionWithLesserMajorVersion(): void + { + $context = [ + 'version' => '1.0.0', + ]; + + $condition = new SemverLessThanOrEqualsCondition('version', '2.0.0'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanOrEqualsConditionWithLesserMinorVersion(): void + { + $context = [ + 'version' => '1.1.0', + ]; + + $condition = new SemverLessThanOrEqualsCondition('version', '1.2.0'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanOrEqualsConditionWithLesserPatchVersion(): void + { + $context = [ + 'version' => '1.2.2', + ]; + + $condition = new SemverLessThanOrEqualsCondition('version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanOrEqualsConditionWithNestedAttributeLesser(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.2', + ], + ], + ]; + + $condition = new SemverLessThanOrEqualsCondition('app.info.version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanOrEqualsConditionWithNestedAttributeEqual(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.3', + ], + ], + ]; + + $condition = new SemverLessThanOrEqualsCondition('app.info.version', '1.2.3'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanOrEqualsConditionWithNestedAttributeGreater(): void + { + $context = [ + 'app' => [ + 'info' => [ + 'version' => '1.2.4', + ], + ], + ]; + + $condition = new SemverLessThanOrEqualsCondition('app.info.version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanOrEqualsConditionWithMissingAttribute(): void + { + $context = [ + 'other_attribute' => '1.2.2', + ]; + + $condition = new SemverLessThanOrEqualsCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanOrEqualsConditionWithInvalidVersionFormat(): void + { + $context = [ + 'version' => 'not-a-version', + ]; + + $condition = new SemverLessThanOrEqualsCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Conditions/StartsWithConditionTest.php b/tests/Datafile/Conditions/StartsWithConditionTest.php new file mode 100644 index 0000000..05e58a7 --- /dev/null +++ b/tests/Datafile/Conditions/StartsWithConditionTest.php @@ -0,0 +1,98 @@ + 'iPhone 12', + ]; + + $condition = new StartsWithCondition('device', 'iPhone'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testStartsWithConditionWithSimpleAttributeNotStartingWith(): void + { + $context = [ + 'device' => 'Android iPhone', + ]; + + $condition = new StartsWithCondition('device', 'iPhone'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testStartsWithConditionWithMissingAttribute(): void + { + $this->expectException(AttributeException::class); + + $context = [ + 'other_attribute' => 'iPhone 12', + ]; + $condition = new StartsWithCondition('device', 'iPhone'); + + $condition->isSatisfiedBy($context); + } + + public function testStartsWithConditionWithNestedAttributeStartingWith(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'device' => 'iPhone 12', + ], + ], + ]; + + $condition = new StartsWithCondition('user.profile.device', 'iPhone'); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testStartsWithConditionWithNestedAttributeNotStartingWith(): void + { + $context = [ + 'user' => [ + 'profile' => [ + 'device' => 'Android iPhone', + ], + ], + ]; + + $condition = new StartsWithCondition('user.profile.device', 'iPhone'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testStartsWithConditionWithEmptyString(): void + { + $context = [ + 'device' => 'iPhone 12', + ]; + + $condition = new StartsWithCondition('device', ''); + + self::assertTrue($condition->isSatisfiedBy($context)); + } + + public function testStartsWithConditionWithCaseSensitivity(): void + { + $context = [ + 'device' => 'iPhone 12', + ]; + + $condition = new StartsWithCondition('device', 'iphone'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } +} diff --git a/tests/Datafile/Fixture/Segment/complex_expressions.yaml b/tests/Datafile/Fixture/Segment/complex_expressions.yaml new file mode 100644 index 0000000..bf50c7b --- /dev/null +++ b/tests/Datafile/Fixture/Segment/complex_expressions.yaml @@ -0,0 +1,19 @@ +description: Complex expressions +conditions: + - and: + - attribute: device + operator: startsWith + value: iPhone + - not: + - or: + - attribute: country + operator: equals + value: us + - attribute: country + operator: equals + value: ca + - not: + - attribute: age + operator: lessThan + value: 21 + diff --git a/tests/Datafile/Fixture/Segment/everyone.yaml b/tests/Datafile/Fixture/Segment/everyone.yaml new file mode 100644 index 0000000..ceaea31 --- /dev/null +++ b/tests/Datafile/Fixture/Segment/everyone.yaml @@ -0,0 +1,2 @@ +description: Everyone +conditions: '*' diff --git a/tests/Datafile/Fixture/Segment/simple_condition.yaml b/tests/Datafile/Fixture/Segment/simple_condition.yaml new file mode 100644 index 0000000..8861a04 --- /dev/null +++ b/tests/Datafile/Fixture/Segment/simple_condition.yaml @@ -0,0 +1,5 @@ +description: Simple condition +conditions: + - attribute: age + operator: greaterThan + value: 21 diff --git a/tests/Datafile/Fixture/Segment/switzerland.yaml b/tests/Datafile/Fixture/Segment/switzerland.yaml new file mode 100644 index 0000000..5f7de4d --- /dev/null +++ b/tests/Datafile/Fixture/Segment/switzerland.yaml @@ -0,0 +1,7 @@ +archived: false +description: users from Switzerland +conditions: + and: + - attribute: country + operator: equals + value: ch diff --git a/tests/Datafile/Fixture/SegmentFixture.php b/tests/Datafile/Fixture/SegmentFixture.php new file mode 100644 index 0000000..3e6a27f --- /dev/null +++ b/tests/Datafile/Fixture/SegmentFixture.php @@ -0,0 +1,31 @@ + [ + SegmentFixture::everyone(), + new Segment( + 'Everyone', + new Conditions(new Conditions\EveryoneCondition()), + false + ) + ]; + + yield 'simple_conditions' => [ + SegmentFixture::simpleCondition(), + new Segment( + 'Simple condition', + new Conditions(new GreaterThanCondition('age', 21)), + false + ) + ]; + + yield 'complex_expressions' => [ + SegmentFixture::complexExpressions(), + new Segment( + 'Complex expressions', + new Conditions(new Conditions\AndCondition( + new Conditions\AndCondition( + new Conditions\StartsWithCondition('device', 'iPhone'), + new Conditions\NotCondition( + new Conditions\OrCondition( + new Conditions\EqualsCondition('country', 'us'), + new Conditions\EqualsCondition('country', 'ca'), + ) + ) + ), + new Conditions\NotCondition( + new Conditions\LessThanCondition('age', 21) + ) + )), + false + ) + ]; + + yield 'switzerland' => [ + SegmentFixture::switzerland(), + new Segment( + 'users from Switzerland', + new Conditions(new Conditions\AndCondition(new Conditions\EqualsCondition('country', 'ch'))), + false + ) + ]; + } +} diff --git a/tests/DatafileReaderTest.php b/tests/DatafileReaderTest.php index 21a59ea..ca21fc1 100644 --- a/tests/DatafileReaderTest.php +++ b/tests/DatafileReaderTest.php @@ -2,6 +2,7 @@ namespace Featurevisor\Tests; +use Featurevisor\Datafile\Segment; use PHPUnit\Framework\TestCase; use Featurevisor\DatafileReader; @@ -56,9 +57,9 @@ public function testV2DatafileSchemaEntities() { ]); self::assertEquals('1', $reader->getRevision()); self::assertEquals('2', $reader->getSchemaVersion()); - self::assertEquals($datafileJson['segments']['netherlands'], $reader->getSegment('netherlands')); - self::assertEquals('de', $reader->getSegment('germany')['conditions'][0]['value']); - self::assertNull($reader->getSegment('belgium')); + self::assertEquals($datafileJson['segments']['netherlands'], $reader->findSegment('netherlands')); + self::assertEquals('de', $reader->findSegment('germany')['conditions'][0]['value']); + self::assertNull($reader->findSegment('belgium')); self::assertEquals($datafileJson['features']['test'], $reader->getFeature('test')); self::assertNull($reader->getFeature('test2')); }