From f9cbe2711b9fa843c873d12d8e32447e5671d56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Ku=C5=BAnik?= Date: Mon, 18 Aug 2025 23:31:12 +0200 Subject: [PATCH 1/6] feat: Add Segment object representation --- composer.json | 5 +- composer.lock | 306 +++++++++++++++++- src/Datafile/Conditions.php | 40 +++ src/Datafile/Conditions/AfterCondition.php | 29 ++ src/Datafile/Conditions/AndCondition.php | 28 ++ src/Datafile/Conditions/BeforeCondition.php | 29 ++ .../Conditions/CompositeCondition.php | 38 +++ src/Datafile/Conditions/ConditionFactory.php | 129 ++++++++ .../Conditions/ConditionInterface.php | 8 + src/Datafile/Conditions/ContainsCondition.php | 25 ++ src/Datafile/Conditions/ContextLookup.php | 31 ++ src/Datafile/Conditions/EndsWithCondition.php | 25 ++ src/Datafile/Conditions/EqualsCondition.php | 27 ++ src/Datafile/Conditions/EveryoneCondition.php | 14 + src/Datafile/Conditions/ExistsCondition.php | 35 ++ .../Conditions/GreaterThanCondition.php | 31 ++ .../GreaterThanOrEqualsCondition.php | 33 ++ src/Datafile/Conditions/InCondition.php | 35 ++ src/Datafile/Conditions/IncludesCondition.php | 30 ++ src/Datafile/Conditions/LessThanCondition.php | 31 ++ .../Conditions/LessThanOrEqualsCondition.php | 33 ++ src/Datafile/Conditions/MatchesCondition.php | 25 ++ src/Datafile/Conditions/NotCondition.php | 21 ++ src/Datafile/Conditions/OrCondition.php | 28 ++ .../Conditions/SemverEqualsCondition.php | 36 +++ .../Conditions/SemverGreaterThanCondition.php | 36 +++ .../SemverGreaterThanOrEqualsCondition.php | 32 ++ .../Conditions/SemverLessThanCondition.php | 36 +++ .../SemverLessThanOrEqualsCondition.php | 32 ++ .../Conditions/StartsWithCondition.php | 25 ++ src/Datafile/Conditions/VersionComparator.php | 75 +++++ src/Datafile/Content.php | 74 +++++ src/Datafile/Segment.php | 85 +++++ src/Datafile/Semver.php | 54 ++++ .../Fixture/Segment/complex_expressions.yaml | 19 ++ tests/Datafile/Fixture/Segment/everyone.yaml | 2 + .../Fixture/Segment/simple_condition.yaml | 5 + tests/Datafile/Fixture/SegmentFixture.php | 28 ++ tests/Datafile/SegmentTest.php | 67 ++++ 39 files changed, 1639 insertions(+), 3 deletions(-) create mode 100644 src/Datafile/Conditions.php create mode 100644 src/Datafile/Conditions/AfterCondition.php create mode 100644 src/Datafile/Conditions/AndCondition.php create mode 100644 src/Datafile/Conditions/BeforeCondition.php create mode 100644 src/Datafile/Conditions/CompositeCondition.php create mode 100644 src/Datafile/Conditions/ConditionFactory.php create mode 100644 src/Datafile/Conditions/ConditionInterface.php create mode 100644 src/Datafile/Conditions/ContainsCondition.php create mode 100644 src/Datafile/Conditions/ContextLookup.php create mode 100644 src/Datafile/Conditions/EndsWithCondition.php create mode 100644 src/Datafile/Conditions/EqualsCondition.php create mode 100644 src/Datafile/Conditions/EveryoneCondition.php create mode 100644 src/Datafile/Conditions/ExistsCondition.php create mode 100644 src/Datafile/Conditions/GreaterThanCondition.php create mode 100644 src/Datafile/Conditions/GreaterThanOrEqualsCondition.php create mode 100644 src/Datafile/Conditions/InCondition.php create mode 100644 src/Datafile/Conditions/IncludesCondition.php create mode 100644 src/Datafile/Conditions/LessThanCondition.php create mode 100644 src/Datafile/Conditions/LessThanOrEqualsCondition.php create mode 100644 src/Datafile/Conditions/MatchesCondition.php create mode 100644 src/Datafile/Conditions/NotCondition.php create mode 100644 src/Datafile/Conditions/OrCondition.php create mode 100644 src/Datafile/Conditions/SemverEqualsCondition.php create mode 100644 src/Datafile/Conditions/SemverGreaterThanCondition.php create mode 100644 src/Datafile/Conditions/SemverGreaterThanOrEqualsCondition.php create mode 100644 src/Datafile/Conditions/SemverLessThanCondition.php create mode 100644 src/Datafile/Conditions/SemverLessThanOrEqualsCondition.php create mode 100644 src/Datafile/Conditions/StartsWithCondition.php create mode 100644 src/Datafile/Conditions/VersionComparator.php create mode 100644 src/Datafile/Content.php create mode 100644 src/Datafile/Segment.php create mode 100644 src/Datafile/Semver.php create mode 100644 tests/Datafile/Fixture/Segment/complex_expressions.yaml create mode 100644 tests/Datafile/Fixture/Segment/everyone.yaml create mode 100644 tests/Datafile/Fixture/Segment/simple_condition.yaml create mode 100644 tests/Datafile/Fixture/SegmentFixture.php create mode 100644 tests/Datafile/SegmentTest.php diff --git a/composer.json b/composer.json index 5989581..a3eb5b7 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,10 @@ "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" }, "require-dev": { "phpunit/phpunit": "^9", diff --git a/composer.lock b/composer.lock index aad6303..c6e8e86 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": "99f1c56332bc1cda7073c92a8c7296a2", "packages": [ { "name": "psr/log", @@ -55,6 +55,307 @@ "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/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 +2210,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/Datafile/Conditions.php b/src/Datafile/Conditions.php new file mode 100644 index 0000000..47b9a17 --- /dev/null +++ b/src/Datafile/Conditions.php @@ -0,0 +1,40 @@ +create($conditions)); + } + + public function __construct(ConditionInterface $expression) + { + $this->expression = $expression; + } + + public function isSatisfiedBy(array $context): bool + { + return $this->expression->isSatisfiedBy($context); + } +} diff --git a/src/Datafile/Conditions/AfterCondition.php b/src/Datafile/Conditions/AfterCondition.php new file mode 100644 index 0000000..9cdb7a9 --- /dev/null +++ b/src/Datafile/Conditions/AfterCondition.php @@ -0,0 +1,29 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + $contextDate = new DateTimeImmutable($this->getValueFromContext($context, $this->attribute)); + + return $contextDate > $this->value; + } +} diff --git a/src/Datafile/Conditions/AndCondition.php b/src/Datafile/Conditions/AndCondition.php new file mode 100644 index 0000000..0569ca2 --- /dev/null +++ b/src/Datafile/Conditions/AndCondition.php @@ -0,0 +1,28 @@ + */ + private array $conditions; + + public function __construct(ConditionInterface ...$conditions) + { + $this->conditions = $conditions; + } + + public function isSatisfiedBy(array $context): bool + { + foreach ($this->conditions as $condition) { + if ($condition->isSatisfiedBy($context) === false) { + return false; + } + } + + return true; + } +} diff --git a/src/Datafile/Conditions/BeforeCondition.php b/src/Datafile/Conditions/BeforeCondition.php new file mode 100644 index 0000000..a70ad5e --- /dev/null +++ b/src/Datafile/Conditions/BeforeCondition.php @@ -0,0 +1,29 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + $contextDate = new DateTimeImmutable($this->getValueFromContext($context, $this->attribute)); + + return $contextDate < $this->value; + } +} 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 @@ + $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']) || !isset($condition['value'])) { + var_dump($condition); + throw new InvalidArgumentException('Invalid condition format'); + } + + $attribute = $condition['attribute']; + $value = $condition['value']; + + 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 'semverEquals': + return 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..aa47613 --- /dev/null +++ b/src/Datafile/Conditions/ConditionInterface.php @@ -0,0 +1,8 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + return str_contains($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..f9ef1f3 --- /dev/null +++ b/src/Datafile/Conditions/ContextLookup.php @@ -0,0 +1,31 @@ +attribute = $attribute; + $this->value = $value; + } + + public function isSatisfiedBy(array $context): bool + { + return str_ends_with($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..ca53b03 --- /dev/null +++ b/src/Datafile/Conditions/GreaterThanCondition.php @@ -0,0 +1,31 @@ +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/GreaterThanOrEqualsCondition.php b/src/Datafile/Conditions/GreaterThanOrEqualsCondition.php new file mode 100644 index 0000000..263b092 --- /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..c7ccb32 --- /dev/null +++ b/src/Datafile/Conditions/InCondition.php @@ -0,0 +1,35 @@ + */ + 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 + { + return in_array($this->getValueFromContext($context, $this->attribute), $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..c186fb9 --- /dev/null +++ b/src/Datafile/Conditions/LessThanCondition.php @@ -0,0 +1,31 @@ +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/LessThanOrEqualsCondition.php b/src/Datafile/Conditions/LessThanOrEqualsCondition.php new file mode 100644 index 0000000..c383e76 --- /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..78f2446 --- /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, $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..17f31ce --- /dev/null +++ b/src/Datafile/Conditions/NotCondition.php @@ -0,0 +1,21 @@ +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..1de8873 --- /dev/null +++ b/src/Datafile/Conditions/OrCondition.php @@ -0,0 +1,28 @@ + */ + private array $conditions; + + public function __construct(ConditionInterface ...$conditions) + { + $this->conditions = $conditions; + } + + public function isSatisfiedBy(array $context): bool + { + foreach ($this->conditions as $condition) { + if ($condition->isSatisfiedBy($context) === true) { + return true; + } + } + + return false; + } +} diff --git a/src/Datafile/Conditions/SemverEqualsCondition.php b/src/Datafile/Conditions/SemverEqualsCondition.php new file mode 100644 index 0000000..03c3edd --- /dev/null +++ b/src/Datafile/Conditions/SemverEqualsCondition.php @@ -0,0 +1,36 @@ +attribute = $attribute; + $this->value = new Semver($value); + } + + public function isSatisfiedBy(array $context): bool + { + $comparator = new VersionComparator(); + + return $comparator( + new Semver($this->getValueFromContext($context, $this->attribute)), + $this->value + ) === 0; + } +} diff --git a/src/Datafile/Conditions/SemverGreaterThanCondition.php b/src/Datafile/Conditions/SemverGreaterThanCondition.php new file mode 100644 index 0000000..6af01a7 --- /dev/null +++ b/src/Datafile/Conditions/SemverGreaterThanCondition.php @@ -0,0 +1,36 @@ +attribute = $attribute; + $this->value = new Semver($value); + } + + public function isSatisfiedBy(array $context): bool + { + $comparator = new VersionComparator(); + + return $comparator( + new Semver($this->getValueFromContext($context, $this->attribute)), + $this->value + ) === 1; + } +} 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..5c2d7cb --- /dev/null +++ b/src/Datafile/Conditions/SemverLessThanCondition.php @@ -0,0 +1,36 @@ +attribute = $attribute; + $this->value = new Semver($value); + } + + public function isSatisfiedBy(array $context): bool + { + $comparator = new VersionComparator(); + + return $comparator( + new Semver($this->getValueFromContext($context, $this->attribute)), + $this->value + ) === -1; + } +} 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..4145f9c --- /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($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..97cd7f5 --- /dev/null +++ b/src/Datafile/Content.php @@ -0,0 +1,74 @@ + */ + private array $segments; + + /** + * @throws JsonException + */ + public static function createFromPath(string $path): self + { + if (file_exists($path)) { + $content = file_get_contents($path); + } + + return self::createFromArray(json_decode($content, true, 512, JSON_THROW_ON_ERROR)); + } + + /** + * @param array{ + * schemaVersion: string, + * revision: string, + * segments: array + * } $data + */ + private 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..12cffe4 --- /dev/null +++ b/src/Datafile/Segment.php @@ -0,0 +1,85 @@ +|string + */ + private $conditions; // Can be Condition, List, or String + private string $description; + + /** + * @param array|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: array{}|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, + ConditionInterface $conditions, + bool $archived = false + ) + { + $this->archived = $archived; + $this->conditions = $conditions; + $this->description = $description; + } + + public function isArchived(): bool + { + return $this->archived; + } + + public function getKey(): string + { + return $this->key; + } + + /** + * @return Condition|array|string + */ + public function getConditions() + { + return $this->conditions; + } + + public function getDescription(): string + { + return $this->description; + } + +} 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/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/SegmentFixture.php b/tests/Datafile/Fixture/SegmentFixture.php new file mode 100644 index 0000000..3452374 --- /dev/null +++ b/tests/Datafile/Fixture/SegmentFixture.php @@ -0,0 +1,28 @@ + + */ + public static function everyone(): array + { + return Yaml::parseFile(__DIR__ . '/Segment/everyone.yaml'); + } + + public static function simpleCondition(): array + { + return Yaml::parseFile(__DIR__ . '/Segment/simple_condition.yaml'); + } + public static function complexExpressions(): array + { + return Yaml::parseFile(__DIR__ . '/Segment/complex_expressions.yaml'); + } +} diff --git a/tests/Datafile/SegmentTest.php b/tests/Datafile/SegmentTest.php new file mode 100644 index 0000000..89226d7 --- /dev/null +++ b/tests/Datafile/SegmentTest.php @@ -0,0 +1,67 @@ + [ + 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 + ) + ]; + } +} From 2e6b301376dbc78d032e3dfc22d2e872f18f47a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Ku=C5=BAnik?= Date: Tue, 19 Aug 2025 00:09:00 +0200 Subject: [PATCH 2/6] feat: Replace conditions evaluation with new implementation --- src/Conditions.php | 196 ------------------------------------ src/Datafile/Conditions.php | 4 + src/Datafile/Content.php | 13 ++- src/Datafile/Segment.php | 21 +--- src/DatafileReader.php | 74 +------------- 5 files changed, 21 insertions(+), 287 deletions(-) delete mode 100644 src/Conditions.php 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/Conditions.php b/src/Datafile/Conditions.php index 47b9a17..7cc95fc 100644 --- a/src/Datafile/Conditions.php +++ b/src/Datafile/Conditions.php @@ -13,6 +13,10 @@ final class Conditions implements ConditionInterface { private ConditionInterface $expression; + /** + * @param string|list>|array{attribute: string, operator: string, value?: mixed, regexFlags?: string}> $conditions + * @return self + */ public static function createFromMixed($conditions): self { if ($conditions === '*') { diff --git a/src/Datafile/Content.php b/src/Datafile/Content.php index 97cd7f5..4569331 100644 --- a/src/Datafile/Content.php +++ b/src/Datafile/Content.php @@ -19,11 +19,16 @@ final class Content */ public static function createFromPath(string $path): self { - if (file_exists($path)) { - $content = file_get_contents($path); + if (file_exists($path) === false) { + throw new \InvalidArgumentException("File '$path' not found"); } - return self::createFromArray(json_decode($content, true, 512, JSON_THROW_ON_ERROR)); + 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)); } /** @@ -35,7 +40,7 @@ public static function createFromPath(string $path): self * }> * } $data */ - private static function createFromArray(array $data): self + public static function createFromArray(array $data): self { return new self( $data['schemaVersion'], diff --git a/src/Datafile/Segment.php b/src/Datafile/Segment.php index 12cffe4..dc3a29f 100644 --- a/src/Datafile/Segment.php +++ b/src/Datafile/Segment.php @@ -10,16 +10,13 @@ final class Segment { private bool $archived; - /** - * @var Condition|array|string - */ - private $conditions; // Can be Condition, List, or String + private Conditions $conditions; private string $description; /** * @param array|string, + * conditions: list>|string, * description: string * }> $segments * @return array @@ -35,7 +32,7 @@ public static function createManyFromArray(array $segments): array /** * @param array{ * archived: bool, - * conditions: array{}|list|string, + * conditions: list>|string, * description: string * } $data */ @@ -50,7 +47,7 @@ public static function createFromArray(array $data): self public function __construct( string $description, - ConditionInterface $conditions, + Conditions $conditions, bool $archived = false ) { @@ -64,15 +61,7 @@ public function isArchived(): bool return $this->archived; } - public function getKey(): string - { - return $this->key; - } - - /** - * @return Condition|array|string - */ - public function getConditions() + public function getConditions(): Conditions { return $this->conditions; } diff --git a/src/DatafileReader.php b/src/DatafileReader.php index efbb01f..4ca3845 100644 --- a/src/DatafileReader.php +++ b/src/DatafileReader.php @@ -2,6 +2,7 @@ namespace Featurevisor; +use Featurevisor\Datafile\Conditions; use Psr\Log\LoggerInterface; class DatafileReader @@ -93,77 +94,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; + return Conditions::createFromMixed($conditions)->isSatisfiedBy($context); } public function segmentIsMatched(array $segment, array $context): bool @@ -173,6 +104,7 @@ public function segmentIsMatched(array $segment, array $context): bool public function allSegmentsAreMatched($groupSegments, array $context): bool { + var_dump($groupSegments); if ($groupSegments === '*') { return true; } From 5bb6b3dd55686d214e17db69ad3c7e5c35bb47f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Ku=C5=BAnik?= Date: Tue, 19 Aug 2025 01:09:26 +0200 Subject: [PATCH 3/6] test: Add conditions tests --- src/Datafile/Conditions.php | 5 + src/Datafile/Conditions/AfterCondition.php | 23 ++- src/Datafile/Conditions/BeforeCondition.php | 22 ++- src/Datafile/Conditions/ConditionFactory.php | 15 +- src/Datafile/Conditions/ContainsCondition.php | 2 +- src/Datafile/Conditions/EndsWithCondition.php | 2 +- .../Conditions/GreaterThanCondition.php | 7 +- .../GreaterThanOrEqualsCondition.php | 2 +- src/Datafile/Conditions/LessThanCondition.php | 7 +- .../Conditions/LessThanOrEqualsCondition.php | 2 +- src/Datafile/Conditions/MatchesCondition.php | 2 +- .../Conditions/SemverEqualsCondition.php | 6 +- .../Conditions/SemverGreaterThanCondition.php | 6 +- .../Conditions/SemverLessThanCondition.php | 6 +- .../Conditions/StartsWithCondition.php | 2 +- src/Datafile/Segment.php | 8 +- src/DatafileReader.php | 13 +- tests/ConditionsTest.php | 4 +- .../Conditions/AfterConditionTest.php | 108 +++++++++++++ .../Datafile/Conditions/AndConditionTest.php | 108 +++++++++++++ .../Conditions/BeforeConditionTest.php | 108 +++++++++++++ .../Conditions/ContainsConditionTest.php | 96 ++++++++++++ .../Conditions/EndsWithConditionTest.php | 96 ++++++++++++ .../Conditions/EqualsConditionTest.php | 100 ++++++++++++ .../Conditions/EveryoneConditionTest.php | 35 +++++ .../Conditions/ExistsConditionTest.php | 76 +++++++++ .../Conditions/GreaterThanConditionTest.php | 119 ++++++++++++++ .../GreaterThanOrEqualsConditionTest.php | 131 ++++++++++++++++ tests/Datafile/Conditions/InConditionTest.php | 111 ++++++++++++++ .../Conditions/IncludesConditionTest.php | 114 ++++++++++++++ .../Conditions/LessThanConditionTest.php | 119 ++++++++++++++ .../LessThanOrEqualsConditionTest.php | 131 ++++++++++++++++ .../Conditions/MatchesConditionTest.php | 96 ++++++++++++ .../Datafile/Conditions/NotConditionTest.php | 80 ++++++++++ tests/Datafile/Conditions/OrConditionTest.php | 123 +++++++++++++++ .../Conditions/SemverEqualsConditionTest.php | 119 ++++++++++++++ .../SemverGreaterThanConditionTest.php | 130 ++++++++++++++++ ...SemverGreaterThanOrEqualsConditionTest.php | 145 ++++++++++++++++++ .../SemverLessThanConditionTest.php | 130 ++++++++++++++++ .../SemverLessThanOrEqualsConditionTest.php | 145 ++++++++++++++++++ .../Conditions/StartsWithConditionTest.php | 96 ++++++++++++ tests/DatafileReaderTest.php | 7 +- 42 files changed, 2618 insertions(+), 39 deletions(-) create mode 100644 tests/Datafile/Conditions/AfterConditionTest.php create mode 100644 tests/Datafile/Conditions/AndConditionTest.php create mode 100644 tests/Datafile/Conditions/BeforeConditionTest.php create mode 100644 tests/Datafile/Conditions/ContainsConditionTest.php create mode 100644 tests/Datafile/Conditions/EndsWithConditionTest.php create mode 100644 tests/Datafile/Conditions/EqualsConditionTest.php create mode 100644 tests/Datafile/Conditions/EveryoneConditionTest.php create mode 100644 tests/Datafile/Conditions/ExistsConditionTest.php create mode 100644 tests/Datafile/Conditions/GreaterThanConditionTest.php create mode 100644 tests/Datafile/Conditions/GreaterThanOrEqualsConditionTest.php create mode 100644 tests/Datafile/Conditions/InConditionTest.php create mode 100644 tests/Datafile/Conditions/IncludesConditionTest.php create mode 100644 tests/Datafile/Conditions/LessThanConditionTest.php create mode 100644 tests/Datafile/Conditions/LessThanOrEqualsConditionTest.php create mode 100644 tests/Datafile/Conditions/MatchesConditionTest.php create mode 100644 tests/Datafile/Conditions/NotConditionTest.php create mode 100644 tests/Datafile/Conditions/OrConditionTest.php create mode 100644 tests/Datafile/Conditions/SemverEqualsConditionTest.php create mode 100644 tests/Datafile/Conditions/SemverGreaterThanConditionTest.php create mode 100644 tests/Datafile/Conditions/SemverGreaterThanOrEqualsConditionTest.php create mode 100644 tests/Datafile/Conditions/SemverLessThanConditionTest.php create mode 100644 tests/Datafile/Conditions/SemverLessThanOrEqualsConditionTest.php create mode 100644 tests/Datafile/Conditions/StartsWithConditionTest.php diff --git a/src/Datafile/Conditions.php b/src/Datafile/Conditions.php index 7cc95fc..235b462 100644 --- a/src/Datafile/Conditions.php +++ b/src/Datafile/Conditions.php @@ -8,6 +8,7 @@ use Featurevisor\Datafile\Conditions\ConditionFactory; use Featurevisor\Datafile\Conditions\ConditionInterface; use Featurevisor\Datafile\Conditions\EveryoneCondition; +use Featurevisor\Datafile\Conditions\NotCondition; final class Conditions implements ConditionInterface { @@ -23,6 +24,10 @@ public static function createFromMixed($conditions): self 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'); } diff --git a/src/Datafile/Conditions/AfterCondition.php b/src/Datafile/Conditions/AfterCondition.php index 9cdb7a9..292bb0a 100644 --- a/src/Datafile/Conditions/AfterCondition.php +++ b/src/Datafile/Conditions/AfterCondition.php @@ -6,6 +6,8 @@ use DateTimeImmutable; +use DateTimeInterface; +use Exception; final class AfterCondition implements ConditionInterface { @@ -22,8 +24,23 @@ public function __construct(string $attribute, DateTimeImmutable $value) public function isSatisfiedBy(array $context): bool { - $contextDate = new DateTimeImmutable($this->getValueFromContext($context, $this->attribute)); - - return $contextDate > $this->value; + try { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + 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/BeforeCondition.php b/src/Datafile/Conditions/BeforeCondition.php index a70ad5e..685d629 100644 --- a/src/Datafile/Conditions/BeforeCondition.php +++ b/src/Datafile/Conditions/BeforeCondition.php @@ -6,6 +6,7 @@ use DateTimeImmutable; +use DateTimeInterface; final class BeforeCondition implements ConditionInterface { @@ -22,8 +23,23 @@ public function __construct(string $attribute, DateTimeImmutable $value) public function isSatisfiedBy(array $context): bool { - $contextDate = new DateTimeImmutable($this->getValueFromContext($context, $this->attribute)); - - return $contextDate < $this->value; + try { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + 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/ConditionFactory.php b/src/Datafile/Conditions/ConditionFactory.php index e75c9a2..8fd814c 100644 --- a/src/Datafile/Conditions/ConditionFactory.php +++ b/src/Datafile/Conditions/ConditionFactory.php @@ -12,6 +12,10 @@ final class ConditionFactory { public function create(array $conditions): ConditionInterface { + if (array_key_exists('attribute', $conditions)) { + return $this->createCondition($conditions); + } + $mappedConditions = array_map(fn ($condition) => $this->map($condition), $conditions); if (count($mappedConditions) === 1) { @@ -44,13 +48,12 @@ private function map(array $condition): ConditionInterface private function createCondition(array $condition): ConditionInterface { - if (!isset($condition['attribute']) || !isset($condition['operator']) || !isset($condition['value'])) { - var_dump($condition); + if (!isset($condition['attribute']) || !isset($condition['operator'])) { throw new InvalidArgumentException('Invalid condition format'); } $attribute = $condition['attribute']; - $value = $condition['value']; + $value = $condition['value'] ?? null; switch ($condition['operator']) { case 'after': @@ -88,9 +91,13 @@ private function createCondition(array $condition): ConditionInterface case 'lessThanOrEquals': return new LessThanOrEqualsCondition($attribute, $value); case 'matches': - return new MatchesCondition($attribute, sprintf('/%s/%s', $value, $condition['regexFlags'])); + 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': diff --git a/src/Datafile/Conditions/ContainsCondition.php b/src/Datafile/Conditions/ContainsCondition.php index 18a0903..fec37f9 100644 --- a/src/Datafile/Conditions/ContainsCondition.php +++ b/src/Datafile/Conditions/ContainsCondition.php @@ -20,6 +20,6 @@ public function __construct(string $attribute, string $value) public function isSatisfiedBy(array $context): bool { - return str_contains($this->getValueFromContext($context, $this->attribute), $this->value); + return str_contains((string) $this->getValueFromContext($context, $this->attribute), $this->value); } } diff --git a/src/Datafile/Conditions/EndsWithCondition.php b/src/Datafile/Conditions/EndsWithCondition.php index 744090c..b5ca739 100644 --- a/src/Datafile/Conditions/EndsWithCondition.php +++ b/src/Datafile/Conditions/EndsWithCondition.php @@ -20,6 +20,6 @@ public function __construct(string $attribute, string $value) public function isSatisfiedBy(array $context): bool { - return str_ends_with($this->getValueFromContext($context, $this->attribute), $this->value); + return str_ends_with((string) $this->getValueFromContext($context, $this->attribute), $this->value); } } diff --git a/src/Datafile/Conditions/GreaterThanCondition.php b/src/Datafile/Conditions/GreaterThanCondition.php index ca53b03..66b8903 100644 --- a/src/Datafile/Conditions/GreaterThanCondition.php +++ b/src/Datafile/Conditions/GreaterThanCondition.php @@ -26,6 +26,11 @@ public function __construct(string $attribute, $value) public function isSatisfiedBy(array $context): bool { - return $this->getValueFromContext($context, $this->attribute) > $this->value; + $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 index 263b092..e6b70d3 100644 --- a/src/Datafile/Conditions/GreaterThanOrEqualsCondition.php +++ b/src/Datafile/Conditions/GreaterThanOrEqualsCondition.php @@ -16,7 +16,7 @@ final class GreaterThanOrEqualsCondition implements ConditionInterface public function __construct(string $attribute, $value) { - if (is_int($value) === false || is_float($value) === false) { + if (is_int($value) === false && is_float($value) === false) { throw new \InvalidArgumentException('GreaterThanOrEqualCondition value must be float or integer'); } diff --git a/src/Datafile/Conditions/LessThanCondition.php b/src/Datafile/Conditions/LessThanCondition.php index c186fb9..8e721a8 100644 --- a/src/Datafile/Conditions/LessThanCondition.php +++ b/src/Datafile/Conditions/LessThanCondition.php @@ -26,6 +26,11 @@ public function __construct(string $attribute, $value) public function isSatisfiedBy(array $context): bool { - return $this->getValueFromContext($context, $this->attribute) < $this->value; + $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 index c383e76..3100148 100644 --- a/src/Datafile/Conditions/LessThanOrEqualsCondition.php +++ b/src/Datafile/Conditions/LessThanOrEqualsCondition.php @@ -16,7 +16,7 @@ final class LessThanOrEqualsCondition implements ConditionInterface public function __construct(string $attribute, $value) { - if (is_int($value) === false || is_float($value) === false) { + if (is_int($value) === false && is_float($value) === false) { throw new \InvalidArgumentException('LessThanOrEqualsCondition value must be float or integer'); } diff --git a/src/Datafile/Conditions/MatchesCondition.php b/src/Datafile/Conditions/MatchesCondition.php index 78f2446..7180b03 100644 --- a/src/Datafile/Conditions/MatchesCondition.php +++ b/src/Datafile/Conditions/MatchesCondition.php @@ -20,6 +20,6 @@ public function __construct(string $attribute, string $regex) public function isSatisfiedBy(array $context): bool { - return preg_match($this->regex, $this->getValueFromContext($context, $this->attribute)) === 1; + return preg_match($this->regex, (string) $this->getValueFromContext($context, $this->attribute)) === 1; } } diff --git a/src/Datafile/Conditions/SemverEqualsCondition.php b/src/Datafile/Conditions/SemverEqualsCondition.php index 03c3edd..268a8d1 100644 --- a/src/Datafile/Conditions/SemverEqualsCondition.php +++ b/src/Datafile/Conditions/SemverEqualsCondition.php @@ -26,10 +26,14 @@ public function __construct(string $attribute, string $value) public function isSatisfiedBy(array $context): bool { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + if ($valueFromContext === null) { + return false; + } $comparator = new VersionComparator(); return $comparator( - new Semver($this->getValueFromContext($context, $this->attribute)), + new Semver($valueFromContext), $this->value ) === 0; } diff --git a/src/Datafile/Conditions/SemverGreaterThanCondition.php b/src/Datafile/Conditions/SemverGreaterThanCondition.php index 6af01a7..49e9667 100644 --- a/src/Datafile/Conditions/SemverGreaterThanCondition.php +++ b/src/Datafile/Conditions/SemverGreaterThanCondition.php @@ -26,10 +26,14 @@ public function __construct(string $attribute, string $value) public function isSatisfiedBy(array $context): bool { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + if ($valueFromContext === null) { + return false; + } $comparator = new VersionComparator(); return $comparator( - new Semver($this->getValueFromContext($context, $this->attribute)), + new Semver($valueFromContext), $this->value ) === 1; } diff --git a/src/Datafile/Conditions/SemverLessThanCondition.php b/src/Datafile/Conditions/SemverLessThanCondition.php index 5c2d7cb..bba22f5 100644 --- a/src/Datafile/Conditions/SemverLessThanCondition.php +++ b/src/Datafile/Conditions/SemverLessThanCondition.php @@ -26,10 +26,14 @@ public function __construct(string $attribute, string $value) public function isSatisfiedBy(array $context): bool { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); + if ($valueFromContext === null) { + return false; + } $comparator = new VersionComparator(); return $comparator( - new Semver($this->getValueFromContext($context, $this->attribute)), + new Semver($valueFromContext), $this->value ) === -1; } diff --git a/src/Datafile/Conditions/StartsWithCondition.php b/src/Datafile/Conditions/StartsWithCondition.php index 4145f9c..d51503b 100644 --- a/src/Datafile/Conditions/StartsWithCondition.php +++ b/src/Datafile/Conditions/StartsWithCondition.php @@ -20,6 +20,6 @@ public function __construct(string $attribute, string $value) public function isSatisfiedBy(array $context): bool { - return str_starts_with($this->getValueFromContext($context, $this->attribute), $this->value); + return str_starts_with((string) $this->getValueFromContext($context, $this->attribute), $this->value); } } diff --git a/src/Datafile/Segment.php b/src/Datafile/Segment.php index dc3a29f..52d7a73 100644 --- a/src/Datafile/Segment.php +++ b/src/Datafile/Segment.php @@ -61,14 +61,14 @@ public function isArchived(): bool return $this->archived; } - public function getConditions(): Conditions + public function getDescription(): string { - return $this->conditions; + return $this->description; } - public function getDescription(): string + public function allConditionsAreMatched(array $context): bool { - return $this->description; + return $this->conditions->isSatisfiedBy($context); } } diff --git a/src/DatafileReader.php b/src/DatafileReader.php index 4ca3845..d3f22f0 100644 --- a/src/DatafileReader.php +++ b/src/DatafileReader.php @@ -3,6 +3,7 @@ namespace Featurevisor; use Featurevisor\Datafile\Conditions; +use Featurevisor\Datafile\Segment; use Psr\Log\LoggerInterface; class DatafileReader @@ -36,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; @@ -97,21 +98,15 @@ public function allConditionsAreMatched($conditions, array $context): bool return Conditions::createFromMixed($conditions)->isSatisfiedBy($context); } - public function segmentIsMatched(array $segment, array $context): bool - { - return $this->allConditionsAreMatched($segment['conditions'], $context); - } - public function allSegmentsAreMatched($groupSegments, array $context): bool { - var_dump($groupSegments); if ($groupSegments === '*') { return true; } 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..50523f7 100644 --- a/tests/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -291,8 +291,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'])); } diff --git a/tests/Datafile/Conditions/AfterConditionTest.php b/tests/Datafile/Conditions/AfterConditionTest.php new file mode 100644 index 0000000..9ea6a29 --- /dev/null +++ b/tests/Datafile/Conditions/AfterConditionTest.php @@ -0,0 +1,108 @@ + '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 + { + $context = [ + 'other_attribute' => '2023-01-15', + ]; + + $condition = new AfterCondition('date', new DateTimeImmutable('2023-01-01')); + + self::assertFalse($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..4e50856 --- /dev/null +++ b/tests/Datafile/Conditions/BeforeConditionTest.php @@ -0,0 +1,108 @@ + '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 + { + $context = [ + 'other_attribute' => '2022-12-15', + ]; + + $condition = new BeforeCondition('date', new DateTimeImmutable('2023-01-01')); + + self::assertFalse($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..4c1d4ed --- /dev/null +++ b/tests/Datafile/Conditions/ContainsConditionTest.php @@ -0,0 +1,96 @@ + '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 + { + $context = [ + 'other_attribute' => 'iPhone 12', + ]; + + $condition = new ContainsCondition('device', 'iPhone'); + + self::assertFalse($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..81323c5 --- /dev/null +++ b/tests/Datafile/Conditions/EndsWithConditionTest.php @@ -0,0 +1,96 @@ + '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 + { + $context = [ + 'other_attribute' => 'My iPhone', + ]; + + $condition = new EndsWithCondition('device', 'iPhone'); + + self::assertFalse($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..1e42950 --- /dev/null +++ b/tests/Datafile/Conditions/EqualsConditionTest.php @@ -0,0 +1,100 @@ + '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 + { + $context = [ + 'other_attribute' => 'value', + ]; + + $condition = new EqualsCondition('country', 'us'); + + self::assertFalse($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 + { + $context = [ + 'user' => [ + 'profile' => [ + 'name' => 'John', + ], + ], + ]; + + $condition = new EqualsCondition('user.profile.country', 'us'); + + self::assertFalse($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..9d9d42e --- /dev/null +++ b/tests/Datafile/Conditions/GreaterThanConditionTest.php @@ -0,0 +1,119 @@ + 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 + { + $context = [ + 'other_attribute' => 25, + ]; + + $condition = new GreaterThanCondition('age', 21); + + self::assertFalse($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..537fe5b --- /dev/null +++ b/tests/Datafile/Conditions/InConditionTest.php @@ -0,0 +1,111 @@ + '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 + { + $context = [ + 'other_attribute' => 'us', + ]; + + $condition = new InCondition('country', ['us', 'ca', 'uk']); + + self::assertFalse($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]); + } +} diff --git a/tests/Datafile/Conditions/IncludesConditionTest.php b/tests/Datafile/Conditions/IncludesConditionTest.php new file mode 100644 index 0000000..c9f46c7 --- /dev/null +++ b/tests/Datafile/Conditions/IncludesConditionTest.php @@ -0,0 +1,114 @@ + ['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 + { + $context = [ + 'other_attribute' => ['us', 'ca', 'uk'], + ]; + + $condition = new IncludesCondition('countries', 'us'); + + self::assertFalse($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..322dd6c --- /dev/null +++ b/tests/Datafile/Conditions/LessThanConditionTest.php @@ -0,0 +1,119 @@ + 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 + { + $context = [ + 'other_attribute' => 18, + ]; + + $condition = new LessThanCondition('age', 21); + + self::assertFalse($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..1c2102c --- /dev/null +++ b/tests/Datafile/Conditions/MatchesConditionTest.php @@ -0,0 +1,96 @@ + '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 + { + $context = [ + 'other_attribute' => 'user@example.com', + ]; + + $condition = new MatchesCondition('email', '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'); + + self::assertFalse($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..62d0382 --- /dev/null +++ b/tests/Datafile/Conditions/NotConditionTest.php @@ -0,0 +1,80 @@ + '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 + { + $context = [ + 'other_attribute' => 'value', + ]; + + $condition = new NotCondition( + new EqualsCondition('country', 'us') + ); + + self::assertTrue($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..59acb1d --- /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 + { + $context = [ + 'other_attribute' => '1.2.3', + ]; + + $condition = new SemverEqualsCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverEqualsConditionWithInvalidVersionFormat(): void + { + $context = [ + 'version' => 'not-a-version', + ]; + + $condition = new SemverEqualsCondition('version', '1.2.3'); + + $this->expectException(\InvalidArgumentException::class); + $condition->isSatisfiedBy($context); + } +} diff --git a/tests/Datafile/Conditions/SemverGreaterThanConditionTest.php b/tests/Datafile/Conditions/SemverGreaterThanConditionTest.php new file mode 100644 index 0000000..28b437b --- /dev/null +++ b/tests/Datafile/Conditions/SemverGreaterThanConditionTest.php @@ -0,0 +1,130 @@ + '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 + { + $context = [ + 'other_attribute' => '1.2.4', + ]; + + $condition = new SemverGreaterThanCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverGreaterThanConditionWithInvalidVersionFormat(): void + { + $context = [ + 'version' => 'not-a-version', + ]; + + $condition = new SemverGreaterThanCondition('version', '1.2.3'); + + $this->expectException(\InvalidArgumentException::class); + $condition->isSatisfiedBy($context); + } +} diff --git a/tests/Datafile/Conditions/SemverGreaterThanOrEqualsConditionTest.php b/tests/Datafile/Conditions/SemverGreaterThanOrEqualsConditionTest.php new file mode 100644 index 0000000..27bc79a --- /dev/null +++ b/tests/Datafile/Conditions/SemverGreaterThanOrEqualsConditionTest.php @@ -0,0 +1,145 @@ + '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'); + + $this->expectException(\InvalidArgumentException::class); + $condition->isSatisfiedBy($context); + } +} diff --git a/tests/Datafile/Conditions/SemverLessThanConditionTest.php b/tests/Datafile/Conditions/SemverLessThanConditionTest.php new file mode 100644 index 0000000..a879420 --- /dev/null +++ b/tests/Datafile/Conditions/SemverLessThanConditionTest.php @@ -0,0 +1,130 @@ + '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 + { + $context = [ + 'other_attribute' => '1.2.2', + ]; + + $condition = new SemverLessThanCondition('version', '1.2.3'); + + self::assertFalse($condition->isSatisfiedBy($context)); + } + + public function testSemverLessThanConditionWithInvalidVersionFormat(): void + { + $context = [ + 'version' => 'not-a-version', + ]; + + $condition = new SemverLessThanCondition('version', '1.2.3'); + + $this->expectException(\InvalidArgumentException::class); + $condition->isSatisfiedBy($context); + } +} diff --git a/tests/Datafile/Conditions/SemverLessThanOrEqualsConditionTest.php b/tests/Datafile/Conditions/SemverLessThanOrEqualsConditionTest.php new file mode 100644 index 0000000..e8a66fe --- /dev/null +++ b/tests/Datafile/Conditions/SemverLessThanOrEqualsConditionTest.php @@ -0,0 +1,145 @@ + '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'); + + $this->expectException(\InvalidArgumentException::class); + $condition->isSatisfiedBy($context); + } +} diff --git a/tests/Datafile/Conditions/StartsWithConditionTest.php b/tests/Datafile/Conditions/StartsWithConditionTest.php new file mode 100644 index 0000000..c7936d4 --- /dev/null +++ b/tests/Datafile/Conditions/StartsWithConditionTest.php @@ -0,0 +1,96 @@ + '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 + { + $context = [ + 'other_attribute' => 'iPhone 12', + ]; + + $condition = new StartsWithCondition('device', 'iPhone'); + + self::assertFalse($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/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')); } From 0f5ca7a7aec5fa66a972a20fee95601ca8531d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Ku=C5=BAnik?= Date: Wed, 20 Aug 2025 22:54:33 +0200 Subject: [PATCH 4/6] fix: Fix conditions according to example tests --- composer.json | 3 +- composer.lock | 82 ++++++++++++++++++- src/Datafile/AttributeException.php | 20 +++++ src/Datafile/Conditions.php | 6 +- src/Datafile/Conditions/AndCondition.php | 8 +- src/Datafile/Conditions/ConditionFactory.php | 4 +- .../Conditions/ConditionInterface.php | 5 ++ src/Datafile/Conditions/ContextLookup.php | 25 +++++- src/Datafile/Conditions/InCondition.php | 8 +- src/Datafile/Conditions/NotCondition.php | 8 +- src/Datafile/Conditions/OrCondition.php | 10 ++- tests/ConditionsTest.php | 19 +++++ tests/Datafile/Conditions/InConditionTest.php | 12 +++ .../Datafile/Fixture/Segment/switzerland.yaml | 7 ++ tests/Datafile/Fixture/SegmentFixture.php | 9 +- tests/Datafile/SegmentTest.php | 9 ++ 16 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 src/Datafile/AttributeException.php create mode 100644 tests/Datafile/Fixture/Segment/switzerland.yaml diff --git a/composer.json b/composer.json index a3eb5b7..50ccb0f 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,8 @@ "ext-mbstring": "*", "psr/log": "^1.1", "symfony/polyfill-php80": "^1.32", - "symfony/yaml": "^5.4" + "symfony/yaml": "^5.4", + "symfony/polyfill-php81": "^1.33" }, "require-dev": { "phpunit/phpunit": "^9", diff --git a/composer.lock b/composer.lock index c6e8e86..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": "99f1c56332bc1cda7073c92a8c7296a2", + "content-hash": "2903afdee6032caec39bf340fcd1515a", "packages": [ { "name": "psr/log", @@ -282,6 +282,86 @@ ], "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", 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 @@ +expression->isSatisfiedBy($context); + try { + return $this->expression->isSatisfiedBy($context); + } catch (AttributeException $e) { + return false; + } } } diff --git a/src/Datafile/Conditions/AndCondition.php b/src/Datafile/Conditions/AndCondition.php index 0569ca2..f618e42 100644 --- a/src/Datafile/Conditions/AndCondition.php +++ b/src/Datafile/Conditions/AndCondition.php @@ -5,6 +5,8 @@ namespace Featurevisor\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; + final class AndCondition implements ConditionInterface { /** @var array */ @@ -18,7 +20,11 @@ public function __construct(ConditionInterface ...$conditions) public function isSatisfiedBy(array $context): bool { foreach ($this->conditions as $condition) { - if ($condition->isSatisfiedBy($context) === false) { + try { + if ($condition->isSatisfiedBy($context) === false) { + return false; + } + } catch (AttributeException $e) { return false; } } diff --git a/src/Datafile/Conditions/ConditionFactory.php b/src/Datafile/Conditions/ConditionFactory.php index 8fd814c..3f3381e 100644 --- a/src/Datafile/Conditions/ConditionFactory.php +++ b/src/Datafile/Conditions/ConditionFactory.php @@ -12,8 +12,8 @@ final class ConditionFactory { public function create(array $conditions): ConditionInterface { - if (array_key_exists('attribute', $conditions)) { - return $this->createCondition($conditions); + if (array_is_list($conditions) === false) { + return $this->map($conditions); } $mappedConditions = array_map(fn ($condition) => $this->map($condition), $conditions); diff --git a/src/Datafile/Conditions/ConditionInterface.php b/src/Datafile/Conditions/ConditionInterface.php index aa47613..05cc430 100644 --- a/src/Datafile/Conditions/ConditionInterface.php +++ b/src/Datafile/Conditions/ConditionInterface.php @@ -2,7 +2,12 @@ namespace Featurevisor\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; + interface ConditionInterface { + /** + * @throws AttributeException + */ public function isSatisfiedBy(array $context): bool; } diff --git a/src/Datafile/Conditions/ContextLookup.php b/src/Datafile/Conditions/ContextLookup.php index f9ef1f3..cc57120 100644 --- a/src/Datafile/Conditions/ContextLookup.php +++ b/src/Datafile/Conditions/ContextLookup.php @@ -5,27 +5,46 @@ namespace Featurevisor\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; + trait ContextLookup { /** * @return mixed|null + * @throws AttributeException */ public function getValueFromContext(array $context, string $attribute) { if (strpos($attribute, '.') === false) { - return $context[$attribute] ?? null; + if (array_key_exists($attribute, $context) === false) { + throw AttributeException::createForNotFoundAttribute($attribute); + } + + return $context[$attribute]; } $keys = explode('.', $attribute); $current = $context; foreach ($keys as $key) { - if (!is_array($current) || !isset($current[$key])) { - return null; + if (is_array($current) === false || array_key_exists($key, $current) === false) { + throw AttributeException::createForNotFoundAttribute($attribute); } $current = $current[$key]; } return $current; } + + /** + * @param array $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/InCondition.php b/src/Datafile/Conditions/InCondition.php index c7ccb32..42973af 100644 --- a/src/Datafile/Conditions/InCondition.php +++ b/src/Datafile/Conditions/InCondition.php @@ -5,10 +5,13 @@ namespace Featurevisor\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; + final class InCondition implements ConditionInterface { use ContextLookup; + private const ALLOWED_TYPES = ['string', 'integer']; private string $attribute; /** @var array */ private array $value; @@ -30,6 +33,9 @@ public function __construct(string $attribute, array $value) public function isSatisfiedBy(array $context): bool { - return in_array($this->getValueFromContext($context, $this->attribute), $this->value, true); + $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/NotCondition.php b/src/Datafile/Conditions/NotCondition.php index 17f31ce..3024e4d 100644 --- a/src/Datafile/Conditions/NotCondition.php +++ b/src/Datafile/Conditions/NotCondition.php @@ -5,6 +5,8 @@ namespace Featurevisor\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; + final class NotCondition implements ConditionInterface { private ConditionInterface $specification; @@ -16,6 +18,10 @@ public function __construct(ConditionInterface $specification) public function isSatisfiedBy(array $context): bool { - return $this->specification->isSatisfiedBy($context) === false; + try { + return $this->specification->isSatisfiedBy($context) === false; + } catch (AttributeException $e) { + return false; + } } } diff --git a/src/Datafile/Conditions/OrCondition.php b/src/Datafile/Conditions/OrCondition.php index 1de8873..76bf709 100644 --- a/src/Datafile/Conditions/OrCondition.php +++ b/src/Datafile/Conditions/OrCondition.php @@ -5,6 +5,8 @@ namespace Featurevisor\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; + final class OrCondition implements ConditionInterface { /** @var array */ @@ -18,8 +20,12 @@ public function __construct(ConditionInterface ...$conditions) public function isSatisfiedBy(array $context): bool { foreach ($this->conditions as $condition) { - if ($condition->isSatisfiedBy($context) === true) { - return true; + try { + if ($condition->isSatisfiedBy($context) === true) { + return true; + } + } catch (AttributeException $e) { + continue; } } diff --git a/tests/ConditionsTest.php b/tests/ConditionsTest.php index 50523f7..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; @@ -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/InConditionTest.php b/tests/Datafile/Conditions/InConditionTest.php index 537fe5b..755094d 100644 --- a/tests/Datafile/Conditions/InConditionTest.php +++ b/tests/Datafile/Conditions/InConditionTest.php @@ -5,6 +5,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; use Featurevisor\Datafile\Conditions\InCondition; +use Featurevisor\Datafile\Conditions\NotCondition; use PHPUnit\Framework\TestCase; class InConditionTest extends TestCase @@ -108,4 +109,15 @@ public function testInConditionWithInvalidValueType(): void new InCondition('country', ['us', 42]); } + + public function testNotInConditionWithMissingAttribute(): void + { + $context = [ + 'country' => 'us', + ]; + + $condition = new NotCondition(new InCondition('continent', ['europe'])); + + self::assertFalse($condition->isSatisfiedBy($context)); + } } 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 index 3452374..3e6a27f 100644 --- a/tests/Datafile/Fixture/SegmentFixture.php +++ b/tests/Datafile/Fixture/SegmentFixture.php @@ -9,9 +9,6 @@ final class SegmentFixture { - /** - * @return array - */ public static function everyone(): array { return Yaml::parseFile(__DIR__ . '/Segment/everyone.yaml'); @@ -21,8 +18,14 @@ public static function simpleCondition(): array { return Yaml::parseFile(__DIR__ . '/Segment/simple_condition.yaml'); } + public static function complexExpressions(): array { return Yaml::parseFile(__DIR__ . '/Segment/complex_expressions.yaml'); } + + public static function switzerland(): array + { + return Yaml::parseFile(__DIR__ . '/Segment/switzerland.yaml'); + } } diff --git a/tests/Datafile/SegmentTest.php b/tests/Datafile/SegmentTest.php index 89226d7..73d0a40 100644 --- a/tests/Datafile/SegmentTest.php +++ b/tests/Datafile/SegmentTest.php @@ -63,5 +63,14 @@ public function segmentDataProvider(): iterable false ) ]; + + yield 'switzerland' => [ + SegmentFixture::switzerland(), + new Segment( + 'users from Switzerland', + new Conditions(new Conditions\AndCondition(new Conditions\EqualsCondition('country', 'ch'))), + false + ) + ]; } } From 4ad2eb5a3632cf1fd84ac81654a0793748455b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Ku=C5=BAnik?= Date: Wed, 20 Aug 2025 23:07:09 +0200 Subject: [PATCH 5/6] fix: Add Semver conditions proper error handling --- .../Conditions/SemverEqualsCondition.php | 19 ++++++++++--------- .../Conditions/SemverGreaterThanCondition.php | 19 ++++++++++--------- .../Conditions/SemverLessThanCondition.php | 16 ++++++++++------ 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/Datafile/Conditions/SemverEqualsCondition.php b/src/Datafile/Conditions/SemverEqualsCondition.php index 268a8d1..6b29573 100644 --- a/src/Datafile/Conditions/SemverEqualsCondition.php +++ b/src/Datafile/Conditions/SemverEqualsCondition.php @@ -13,15 +13,12 @@ final class SemverEqualsCondition implements ConditionInterface use ContextLookup; private string $attribute; - private Semver $value; + private string $value; - /** - * @throws InvalidArgumentException - */ public function __construct(string $attribute, string $value) { $this->attribute = $attribute; - $this->value = new Semver($value); + $this->value = $value; } public function isSatisfiedBy(array $context): bool @@ -32,9 +29,13 @@ public function isSatisfiedBy(array $context): bool } $comparator = new VersionComparator(); - return $comparator( - new Semver($valueFromContext), - $this->value - ) === 0; + 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 index 49e9667..4f60e5d 100644 --- a/src/Datafile/Conditions/SemverGreaterThanCondition.php +++ b/src/Datafile/Conditions/SemverGreaterThanCondition.php @@ -13,15 +13,12 @@ final class SemverGreaterThanCondition implements ConditionInterface use ContextLookup, CompositeCondition; private string $attribute; - private Semver $value; + private string $value; - /** - * @throws InvalidArgumentException - */ public function __construct(string $attribute, string $value) { $this->attribute = $attribute; - $this->value = new Semver($value); + $this->value = $value; } public function isSatisfiedBy(array $context): bool @@ -32,9 +29,13 @@ public function isSatisfiedBy(array $context): bool } $comparator = new VersionComparator(); - return $comparator( - new Semver($valueFromContext), - $this->value - ) === 1; + try { + return $comparator( + new Semver($valueFromContext), + new Semver($this->value) + ) === 1; + } catch (InvalidArgumentException $e) { + return false; + } } } diff --git a/src/Datafile/Conditions/SemverLessThanCondition.php b/src/Datafile/Conditions/SemverLessThanCondition.php index bba22f5..71c580b 100644 --- a/src/Datafile/Conditions/SemverLessThanCondition.php +++ b/src/Datafile/Conditions/SemverLessThanCondition.php @@ -13,7 +13,7 @@ final class SemverLessThanCondition implements ConditionInterface use ContextLookup, CompositeCondition; private string $attribute; - private Semver $value; + private string $value; /** * @throws InvalidArgumentException @@ -21,7 +21,7 @@ final class SemverLessThanCondition implements ConditionInterface public function __construct(string $attribute, string $value) { $this->attribute = $attribute; - $this->value = new Semver($value); + $this->value = $value; } public function isSatisfiedBy(array $context): bool @@ -32,9 +32,13 @@ public function isSatisfiedBy(array $context): bool } $comparator = new VersionComparator(); - return $comparator( - new Semver($valueFromContext), - $this->value - ) === -1; + try { + return $comparator( + new Semver($valueFromContext), + new Semver($this->value) + ) === -1; + } catch (InvalidArgumentException $e) { + return false; + } } } From 9b358310022add7e82534f26610900097cfd0c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Ku=C5=BAnik?= Date: Wed, 20 Aug 2025 23:27:00 +0200 Subject: [PATCH 6/6] test: Fix test cases according to AttributeException --- src/Datafile/Conditions/AfterCondition.php | 2 +- src/Datafile/Conditions/BeforeCondition.php | 2 +- src/Datafile/Conditions/NotCondition.php | 6 +----- tests/Datafile/Conditions/AfterConditionTest.php | 6 ++++-- tests/Datafile/Conditions/BeforeConditionTest.php | 6 ++++-- tests/Datafile/Conditions/ContainsConditionTest.php | 6 ++++-- tests/Datafile/Conditions/EndsWithConditionTest.php | 6 ++++-- tests/Datafile/Conditions/EqualsConditionTest.php | 11 +++++++---- .../Datafile/Conditions/GreaterThanConditionTest.php | 6 ++++-- tests/Datafile/Conditions/InConditionTest.php | 11 +++++++---- tests/Datafile/Conditions/IncludesConditionTest.php | 6 ++++-- tests/Datafile/Conditions/LessThanConditionTest.php | 6 ++++-- tests/Datafile/Conditions/MatchesConditionTest.php | 6 ++++-- tests/Datafile/Conditions/NotConditionTest.php | 6 ++++-- .../Datafile/Conditions/SemverEqualsConditionTest.php | 10 +++++----- .../Conditions/SemverGreaterThanConditionTest.php | 9 +++++---- .../SemverGreaterThanOrEqualsConditionTest.php | 3 +-- .../Conditions/SemverLessThanConditionTest.php | 9 +++++---- .../SemverLessThanOrEqualsConditionTest.php | 3 +-- tests/Datafile/Conditions/StartsWithConditionTest.php | 6 ++++-- 20 files changed, 74 insertions(+), 52 deletions(-) diff --git a/src/Datafile/Conditions/AfterCondition.php b/src/Datafile/Conditions/AfterCondition.php index 292bb0a..061a1dc 100644 --- a/src/Datafile/Conditions/AfterCondition.php +++ b/src/Datafile/Conditions/AfterCondition.php @@ -24,8 +24,8 @@ public function __construct(string $attribute, DateTimeImmutable $value) public function isSatisfiedBy(array $context): bool { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); try { - $valueFromContext = $this->getValueFromContext($context, $this->attribute); if ($valueFromContext === null) { return false; } diff --git a/src/Datafile/Conditions/BeforeCondition.php b/src/Datafile/Conditions/BeforeCondition.php index 685d629..118499a 100644 --- a/src/Datafile/Conditions/BeforeCondition.php +++ b/src/Datafile/Conditions/BeforeCondition.php @@ -23,8 +23,8 @@ public function __construct(string $attribute, DateTimeImmutable $value) public function isSatisfiedBy(array $context): bool { + $valueFromContext = $this->getValueFromContext($context, $this->attribute); try { - $valueFromContext = $this->getValueFromContext($context, $this->attribute); if ($valueFromContext === null) { return false; } diff --git a/src/Datafile/Conditions/NotCondition.php b/src/Datafile/Conditions/NotCondition.php index 3024e4d..752fa4c 100644 --- a/src/Datafile/Conditions/NotCondition.php +++ b/src/Datafile/Conditions/NotCondition.php @@ -18,10 +18,6 @@ public function __construct(ConditionInterface $specification) public function isSatisfiedBy(array $context): bool { - try { - return $this->specification->isSatisfiedBy($context) === false; - } catch (AttributeException $e) { - return false; - } + return $this->specification->isSatisfiedBy($context) === false; } } diff --git a/tests/Datafile/Conditions/AfterConditionTest.php b/tests/Datafile/Conditions/AfterConditionTest.php index 9ea6a29..a1843d8 100644 --- a/tests/Datafile/Conditions/AfterConditionTest.php +++ b/tests/Datafile/Conditions/AfterConditionTest.php @@ -5,6 +5,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; use DateTimeImmutable; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\AfterCondition; use PHPUnit\Framework\TestCase; @@ -75,13 +76,14 @@ public function testAfterConditionWithNestedAttributeBefore(): void public function testAfterConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => '2023-01-15', ]; - $condition = new AfterCondition('date', new DateTimeImmutable('2023-01-01')); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testAfterConditionWithInvalidDateFormat(): void diff --git a/tests/Datafile/Conditions/BeforeConditionTest.php b/tests/Datafile/Conditions/BeforeConditionTest.php index 4e50856..875d315 100644 --- a/tests/Datafile/Conditions/BeforeConditionTest.php +++ b/tests/Datafile/Conditions/BeforeConditionTest.php @@ -5,6 +5,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; use DateTimeImmutable; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\BeforeCondition; use PHPUnit\Framework\TestCase; @@ -75,13 +76,14 @@ public function testBeforeConditionWithNestedAttributeAfter(): void public function testBeforeConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => '2022-12-15', ]; - $condition = new BeforeCondition('date', new DateTimeImmutable('2023-01-01')); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testBeforeConditionWithInvalidDateFormat(): void diff --git a/tests/Datafile/Conditions/ContainsConditionTest.php b/tests/Datafile/Conditions/ContainsConditionTest.php index 4c1d4ed..171fd07 100644 --- a/tests/Datafile/Conditions/ContainsConditionTest.php +++ b/tests/Datafile/Conditions/ContainsConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\ContainsCondition; use PHPUnit\Framework\TestCase; @@ -33,13 +34,14 @@ public function testContainsConditionWithSimpleAttributeNotContaining(): void public function testContainsConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => 'iPhone 12', ]; - $condition = new ContainsCondition('device', 'iPhone'); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testContainsConditionWithNestedAttributeContaining(): void diff --git a/tests/Datafile/Conditions/EndsWithConditionTest.php b/tests/Datafile/Conditions/EndsWithConditionTest.php index 81323c5..d7d1931 100644 --- a/tests/Datafile/Conditions/EndsWithConditionTest.php +++ b/tests/Datafile/Conditions/EndsWithConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\EndsWithCondition; use PHPUnit\Framework\TestCase; @@ -33,13 +34,14 @@ public function testEndsWithConditionWithSimpleAttributeNotEndingWith(): void public function testEndsWithConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => 'My iPhone', ]; - $condition = new EndsWithCondition('device', 'iPhone'); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testEndsWithConditionWithNestedAttributeEndingWith(): void diff --git a/tests/Datafile/Conditions/EqualsConditionTest.php b/tests/Datafile/Conditions/EqualsConditionTest.php index 1e42950..cf1c15a 100644 --- a/tests/Datafile/Conditions/EqualsConditionTest.php +++ b/tests/Datafile/Conditions/EqualsConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\EqualsCondition; use PHPUnit\Framework\TestCase; @@ -33,13 +34,14 @@ public function testEqualsConditionWithSimpleAttributeNotMatching(): void public function testEqualsConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => 'value', ]; - $condition = new EqualsCondition('country', 'us'); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testEqualsConditionWithNestedAttributeMatching(): void @@ -74,6 +76,8 @@ public function testEqualsConditionWithNestedAttributeNotMatching(): void public function testEqualsConditionWithMissingNestedAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'user' => [ 'profile' => [ @@ -81,10 +85,9 @@ public function testEqualsConditionWithMissingNestedAttribute(): void ], ], ]; - $condition = new EqualsCondition('user.profile.country', 'us'); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testEqualsConditionWithDifferentTypes(): void diff --git a/tests/Datafile/Conditions/GreaterThanConditionTest.php b/tests/Datafile/Conditions/GreaterThanConditionTest.php index 9d9d42e..3f97809 100644 --- a/tests/Datafile/Conditions/GreaterThanConditionTest.php +++ b/tests/Datafile/Conditions/GreaterThanConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\GreaterThanCondition; use PHPUnit\Framework\TestCase; @@ -44,13 +45,14 @@ public function testGreaterThanConditionWithSimpleAttributeLess(): void public function testGreaterThanConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => 25, ]; - $condition = new GreaterThanCondition('age', 21); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testGreaterThanConditionWithNestedAttributeGreater(): void diff --git a/tests/Datafile/Conditions/InConditionTest.php b/tests/Datafile/Conditions/InConditionTest.php index 755094d..ee0e8d1 100644 --- a/tests/Datafile/Conditions/InConditionTest.php +++ b/tests/Datafile/Conditions/InConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\InCondition; use Featurevisor\Datafile\Conditions\NotCondition; use PHPUnit\Framework\TestCase; @@ -34,13 +35,14 @@ public function testInConditionWithSimpleAttributeNotInArray(): void public function testInConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => 'us', ]; - $condition = new InCondition('country', ['us', 'ca', 'uk']); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testInConditionWithNestedAttributeInArray(): void @@ -112,12 +114,13 @@ public function testInConditionWithInvalidValueType(): void public function testNotInConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'country' => 'us', ]; - $condition = new NotCondition(new InCondition('continent', ['europe'])); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } } diff --git a/tests/Datafile/Conditions/IncludesConditionTest.php b/tests/Datafile/Conditions/IncludesConditionTest.php index c9f46c7..746e107 100644 --- a/tests/Datafile/Conditions/IncludesConditionTest.php +++ b/tests/Datafile/Conditions/IncludesConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\IncludesCondition; use PHPUnit\Framework\TestCase; @@ -33,13 +34,14 @@ public function testIncludesConditionWithArrayNotIncludingValue(): void public function testIncludesConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => ['us', 'ca', 'uk'], ]; - $condition = new IncludesCondition('countries', 'us'); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testIncludesConditionWithNestedArrayIncludingValue(): void diff --git a/tests/Datafile/Conditions/LessThanConditionTest.php b/tests/Datafile/Conditions/LessThanConditionTest.php index 322dd6c..9e9d191 100644 --- a/tests/Datafile/Conditions/LessThanConditionTest.php +++ b/tests/Datafile/Conditions/LessThanConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\LessThanCondition; use PHPUnit\Framework\TestCase; @@ -44,13 +45,14 @@ public function testLessThanConditionWithSimpleAttributeGreater(): void public function testLessThanConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => 18, ]; - $condition = new LessThanCondition('age', 21); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testLessThanConditionWithNestedAttributeLess(): void diff --git a/tests/Datafile/Conditions/MatchesConditionTest.php b/tests/Datafile/Conditions/MatchesConditionTest.php index 1c2102c..774657b 100644 --- a/tests/Datafile/Conditions/MatchesConditionTest.php +++ b/tests/Datafile/Conditions/MatchesConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\MatchesCondition; use PHPUnit\Framework\TestCase; @@ -33,13 +34,14 @@ public function testMatchesConditionWithSimpleAttributeNotMatching(): void 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,}$/'); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testMatchesConditionWithNestedAttributeMatching(): void diff --git a/tests/Datafile/Conditions/NotConditionTest.php b/tests/Datafile/Conditions/NotConditionTest.php index 62d0382..c0ae1db 100644 --- a/tests/Datafile/Conditions/NotConditionTest.php +++ b/tests/Datafile/Conditions/NotConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\EqualsCondition; use Featurevisor\Datafile\Conditions\GreaterThanCondition; use Featurevisor\Datafile\Conditions\NotCondition; @@ -39,15 +40,16 @@ public function testNotConditionWithUnsatisfiedCondition(): void public function testNotConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => 'value', ]; - $condition = new NotCondition( new EqualsCondition('country', 'us') ); - self::assertTrue($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testNotConditionWithNestedCondition(): void diff --git a/tests/Datafile/Conditions/SemverEqualsConditionTest.php b/tests/Datafile/Conditions/SemverEqualsConditionTest.php index 59acb1d..9ecf32a 100644 --- a/tests/Datafile/Conditions/SemverEqualsConditionTest.php +++ b/tests/Datafile/Conditions/SemverEqualsConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\SemverEqualsCondition; use PHPUnit\Framework\TestCase; @@ -96,13 +97,14 @@ public function testSemverEqualsConditionWithNestedAttributeNotEqual(): void public function testSemverEqualsConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => '1.2.3', ]; - $condition = new SemverEqualsCondition('version', '1.2.3'); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testSemverEqualsConditionWithInvalidVersionFormat(): void @@ -110,10 +112,8 @@ public function testSemverEqualsConditionWithInvalidVersionFormat(): void $context = [ 'version' => 'not-a-version', ]; - $condition = new SemverEqualsCondition('version', '1.2.3'); - $this->expectException(\InvalidArgumentException::class); - $condition->isSatisfiedBy($context); + self::assertFalse($condition->isSatisfiedBy($context)); } } diff --git a/tests/Datafile/Conditions/SemverGreaterThanConditionTest.php b/tests/Datafile/Conditions/SemverGreaterThanConditionTest.php index 28b437b..815eab3 100644 --- a/tests/Datafile/Conditions/SemverGreaterThanConditionTest.php +++ b/tests/Datafile/Conditions/SemverGreaterThanConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\SemverGreaterThanCondition; use PHPUnit\Framework\TestCase; @@ -107,13 +108,14 @@ public function testSemverGreaterThanConditionWithNestedAttributeLesser(): void public function testSemverGreaterThanConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => '1.2.4', ]; - $condition = new SemverGreaterThanCondition('version', '1.2.3'); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testSemverGreaterThanConditionWithInvalidVersionFormat(): void @@ -124,7 +126,6 @@ public function testSemverGreaterThanConditionWithInvalidVersionFormat(): void $condition = new SemverGreaterThanCondition('version', '1.2.3'); - $this->expectException(\InvalidArgumentException::class); - $condition->isSatisfiedBy($context); + self::assertFalse($condition->isSatisfiedBy($context)); } } diff --git a/tests/Datafile/Conditions/SemverGreaterThanOrEqualsConditionTest.php b/tests/Datafile/Conditions/SemverGreaterThanOrEqualsConditionTest.php index 27bc79a..7dd0df8 100644 --- a/tests/Datafile/Conditions/SemverGreaterThanOrEqualsConditionTest.php +++ b/tests/Datafile/Conditions/SemverGreaterThanOrEqualsConditionTest.php @@ -139,7 +139,6 @@ public function testSemverGreaterThanOrEqualsConditionWithInvalidVersionFormat() $condition = new SemverGreaterThanOrEqualsCondition('version', '1.2.3'); - $this->expectException(\InvalidArgumentException::class); - $condition->isSatisfiedBy($context); + self::assertFalse($condition->isSatisfiedBy($context)); } } diff --git a/tests/Datafile/Conditions/SemverLessThanConditionTest.php b/tests/Datafile/Conditions/SemverLessThanConditionTest.php index a879420..0dbc2dd 100644 --- a/tests/Datafile/Conditions/SemverLessThanConditionTest.php +++ b/tests/Datafile/Conditions/SemverLessThanConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\SemverLessThanCondition; use PHPUnit\Framework\TestCase; @@ -107,13 +108,14 @@ public function testSemverLessThanConditionWithNestedAttributeGreater(): void public function testSemverLessThanConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => '1.2.2', ]; - $condition = new SemverLessThanCondition('version', '1.2.3'); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testSemverLessThanConditionWithInvalidVersionFormat(): void @@ -124,7 +126,6 @@ public function testSemverLessThanConditionWithInvalidVersionFormat(): void $condition = new SemverLessThanCondition('version', '1.2.3'); - $this->expectException(\InvalidArgumentException::class); - $condition->isSatisfiedBy($context); + self::assertFalse($condition->isSatisfiedBy($context)); } } diff --git a/tests/Datafile/Conditions/SemverLessThanOrEqualsConditionTest.php b/tests/Datafile/Conditions/SemverLessThanOrEqualsConditionTest.php index e8a66fe..98a7669 100644 --- a/tests/Datafile/Conditions/SemverLessThanOrEqualsConditionTest.php +++ b/tests/Datafile/Conditions/SemverLessThanOrEqualsConditionTest.php @@ -139,7 +139,6 @@ public function testSemverLessThanOrEqualsConditionWithInvalidVersionFormat(): v $condition = new SemverLessThanOrEqualsCondition('version', '1.2.3'); - $this->expectException(\InvalidArgumentException::class); - $condition->isSatisfiedBy($context); + self::assertFalse($condition->isSatisfiedBy($context)); } } diff --git a/tests/Datafile/Conditions/StartsWithConditionTest.php b/tests/Datafile/Conditions/StartsWithConditionTest.php index c7936d4..05e58a7 100644 --- a/tests/Datafile/Conditions/StartsWithConditionTest.php +++ b/tests/Datafile/Conditions/StartsWithConditionTest.php @@ -4,6 +4,7 @@ namespace Featurevisor\Tests\Datafile\Conditions; +use Featurevisor\Datafile\AttributeException; use Featurevisor\Datafile\Conditions\StartsWithCondition; use PHPUnit\Framework\TestCase; @@ -33,13 +34,14 @@ public function testStartsWithConditionWithSimpleAttributeNotStartingWith(): voi public function testStartsWithConditionWithMissingAttribute(): void { + $this->expectException(AttributeException::class); + $context = [ 'other_attribute' => 'iPhone 12', ]; - $condition = new StartsWithCondition('device', 'iPhone'); - self::assertFalse($condition->isSatisfiedBy($context)); + $condition->isSatisfiedBy($context); } public function testStartsWithConditionWithNestedAttributeStartingWith(): void