From 0c8f684114a0937ea2b21f015c31129a7814e261 Mon Sep 17 00:00:00 2001 From: AlessandroMinoccheri Date: Fri, 22 May 2026 20:45:02 +0200 Subject: [PATCH] added tests and doc for the feature and that --- README.md | 46 ++++++ tests/Integration/AndThatFilterTest.php | 197 ++++++++++++++++++++++++ tests/Unit/Rules/AndThatShouldTest.php | 181 ++++++++++++++++++++++ 3 files changed, 424 insertions(+) create mode 100644 tests/Integration/AndThatFilterTest.php create mode 100644 tests/Unit/Rules/AndThatShouldTest.php diff --git a/README.md b/README.md index e7dda102..abcab909 100644 --- a/README.md +++ b/README.md @@ -597,6 +597,52 @@ $rules[] = Rule::allClasses() You can use wildcards or the exact name of a class. +### Combining multiple conditions with `andThat()` + +By default, `that()` selects all classes matching a single expression. When you need to narrow the selection further — applying the `should()` check only to classes that satisfy **all** conditions — you can chain one or more `andThat()` calls: + +```php +// Only concrete domain events (non-abstract, named *Event) must be final +$rules[] = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Domain')) + ->andThat(new HaveNameMatching('*Event')) + ->andThat(new IsNotAbstract()) + ->should(new IsFinal()) + ->because('concrete domain events must be immutable value objects'); +``` + +A class is checked against `should()` only if it satisfies **every** `that()` / `andThat()` condition. A class that matches the first condition but not the second is silently skipped — no violation is raised. + +`andThat()` can be combined with `except()`: + +```php +$rules[] = Rule::allClasses() + ->except('App\Controller\LegacyController') + ->that(new ResideInOneOfTheseNamespaces('App\Controller')) + ->andThat(new HaveAttribute('Symfony\Component\HttpKernel\Attribute\AsController')) + ->should(new IsFinal()) + ->because('active controllers must be final'); +``` + +You can also reuse a partially-built rule to create independent branches: + +```php +$domainClasses = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Domain')); + +$rules[] = $domainClasses + ->andThat(new HaveNameMatching('*Event')) + ->should(new IsFinal()) + ->because('domain events must be final'); + +$rules[] = $domainClasses + ->andThat(new HaveNameMatching('*Service')) + ->should(new IsNotAbstract()) + ->because('domain services must be concrete'); +``` + +Each branch is independent — modifying one does not affect the other. + ## Optional parameters and options You can add parameters when you launch the tool. At the moment you can add these parameters and options: * `-v` : with this option you launch Arkitect with the verbose mode to see every parsed file diff --git a/tests/Integration/AndThatFilterTest.php b/tests/Integration/AndThatFilterTest.php new file mode 100644 index 00000000..cebcb7e2 --- /dev/null +++ b/tests/Integration/AndThatFilterTest.php @@ -0,0 +1,197 @@ +createNamespaceAndNamingStructure())->url(); + + $runner = TestRunner::create('8.4'); + + $rule = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Domain')) + ->andThat(new HaveNameMatching('*Event')) + ->should(new IsFinal()) + ->because('domain events must be final'); + + $runner->run($dir, $rule); + + self::assertCount(1, $runner->getViolations()); + self::assertCount(0, $runner->getParsingErrors()); + self::assertEquals('App\Domain\UserCreatedEvent', $runner->getViolations()->get(0)->getFqcn()); + } + + public function test_classes_matching_namespace_and_attribute_but_failing_should_produce_violation(): void + { + // Rule: classes in App\Controller AND having #[AsController] must be final. + // + // ProductsController (#[AsController], not final) → matches both → violation + // LegacyController (#[AsController], final) → matches both → no violation + // UtilityHelper (no attribute) → fails andThat → not checked + $dir = vfsStream::setup('root', null, $this->createNamespaceAndAttributeStructure())->url(); + + $runner = TestRunner::create('8.4'); + + $rule = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Controller')) + ->andThat(new HaveAttribute('AsController')) + ->should(new IsFinal()) + ->because('active controllers must be final'); + + $runner->run($dir, $rule); + + self::assertCount(1, $runner->getViolations()); + self::assertCount(0, $runner->getParsingErrors()); + self::assertEquals('App\Controller\ProductsController', $runner->getViolations()->get(0)->getFqcn()); + } + + public function test_three_chained_and_that_conditions_all_must_match(): void + { + // Rule: classes in App\Domain AND named *Event AND non-abstract must be final. + // + // UserCreatedEvent → matches all three → not final → violation + // AbstractBaseEvent → matches namespace + *Event but IS abstract → third fails → not checked + // OrderService → matches namespace + IsNotAbstract but not *Event → not checked + $dir = vfsStream::setup('root', null, $this->createThreeConditionStructure())->url(); + + $runner = TestRunner::create('8.4'); + + $rule = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Domain')) + ->andThat(new HaveNameMatching('*Event')) + ->andThat(new IsNotAbstract()) + ->should(new IsFinal()) + ->because('concrete domain events must be final'); + + $runner->run($dir, $rule); + + self::assertCount(1, $runner->getViolations()); + self::assertCount(0, $runner->getParsingErrors()); + self::assertEquals('App\Domain\UserCreatedEvent', $runner->getViolations()->get(0)->getFqcn()); + } + + public function test_except_exclusion_is_respected_with_and_that(): void + { + // Same rule as above, but UserCreatedEvent is excluded via except(). + // No violations expected. + $dir = vfsStream::setup('root', null, $this->createNamespaceAndNamingStructure())->url(); + + $runner = TestRunner::create('8.4'); + + $rule = Rule::allClasses() + ->except('App\Domain\UserCreatedEvent') + ->that(new ResideInOneOfTheseNamespaces('App\Domain')) + ->andThat(new HaveNameMatching('*Event')) + ->should(new IsFinal()) + ->because('domain events must be final'); + + $runner->run($dir, $rule); + + self::assertCount(0, $runner->getViolations()); + self::assertCount(0, $runner->getParsingErrors()); + } + + // ------------------------------------------------------------------------- + // Directory structures + // ------------------------------------------------------------------------- + + private function createNamespaceAndNamingStructure(): array + { + return [ + 'Domain' => [ + 'UserCreatedEvent.php' => <<<'EOT' + <<<'EOT' + <<<'EOT' + [ + 'InfrastructureEvent.php' => <<<'EOT' + [ + 'ProductsController.php' => <<<'EOT' + <<<'EOT' + <<<'EOT' + [ + 'UserCreatedEvent.php' => <<<'EOT' + <<<'EOT' + <<<'EOT' + build(); + + $rule = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Controller')) + ->andThat(new HaveNameMatching('*Controller')) + ->should(new IsFinal()) + ->because('controllers must be final'); + + $violations = new Violations(); + $rule->check($class, $violations); + + self::assertCount(1, $violations); + self::assertStringContainsString('App\Controller\UserController', $violations->get(0)->getFqcn()); + } + + public function test_class_matching_all_conditions_and_satisfying_should_produces_no_violation(): void + { + $class = ClassDescription::getBuilder('App\Controller\UserController', 'src/Controller/UserController.php') + ->setFinal(true) + ->build(); + + $rule = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Controller')) + ->andThat(new HaveNameMatching('*Controller')) + ->should(new IsFinal()) + ->because('controllers must be final'); + + $violations = new Violations(); + $rule->check($class, $violations); + + self::assertCount(0, $violations); + } + + public function test_class_matching_only_first_condition_is_not_checked_against_should(): void + { + // UserService is in App\Controller but does NOT match *Controller + // andThat() filters it out — should() must never fire + $class = ClassDescription::getBuilder('App\Controller\UserService', 'src/Controller/UserService.php') + ->build(); + + $rule = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Controller')) + ->andThat(new HaveNameMatching('*Controller')) + ->should(new IsFinal()) + ->because('controllers must be final'); + + $violations = new Violations(); + $rule->check($class, $violations); + + self::assertCount(0, $violations); + } + + public function test_class_matching_only_second_condition_is_not_checked_against_should(): void + { + // ProductController matches *Controller but is NOT in App\Controller + $class = ClassDescription::getBuilder('App\Service\ProductController', 'src/Service/ProductController.php') + ->build(); + + $rule = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Controller')) + ->andThat(new HaveNameMatching('*Controller')) + ->should(new IsFinal()) + ->because('controllers must be final'); + + $violations = new Violations(); + $rule->check($class, $violations); + + self::assertCount(0, $violations); + } + + public function test_class_matching_neither_condition_is_not_checked_against_should(): void + { + $class = ClassDescription::getBuilder('App\Service\UserService', 'src/Service/UserService.php') + ->build(); + + $rule = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Controller')) + ->andThat(new HaveNameMatching('*Controller')) + ->should(new IsFinal()) + ->because('controllers must be final'); + + $violations = new Violations(); + $rule->check($class, $violations); + + self::assertCount(0, $violations); + } + + public function test_three_chained_and_that_all_match_produces_violation(): void + { + // Matches: App\Domain namespace, *Event name, non-abstract + // should(IsFinal) fires — class is not final → violation + $class = ClassDescription::getBuilder('App\Domain\UserCreatedEvent', 'src/Domain/UserCreatedEvent.php') + ->build(); + + $rule = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Domain')) + ->andThat(new HaveNameMatching('*Event')) + ->andThat(new IsNotAbstract()) + ->should(new IsFinal()) + ->because('domain events must be final'); + + $violations = new Violations(); + $rule->check($class, $violations); + + self::assertCount(1, $violations); + } + + public function test_three_chained_and_that_last_condition_not_matched_skips_should(): void + { + // Matches: App\Domain namespace, *Event name + // Does NOT match IsNotAbstract (class IS abstract) → should() is skipped + $class = ClassDescription::getBuilder('App\Domain\AbstractEvent', 'src/Domain/AbstractEvent.php') + ->setAbstract(true) + ->build(); + + $rule = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Domain')) + ->andThat(new HaveNameMatching('*Event')) + ->andThat(new IsNotAbstract()) + ->should(new IsFinal()) + ->because('domain events must be final'); + + $violations = new Violations(); + $rule->check($class, $violations); + + self::assertCount(0, $violations); + } + + public function test_reusing_base_rule_with_different_and_that_produces_independent_rules(): void + { + $base = Rule::allClasses() + ->that(new ResideInOneOfTheseNamespaces('App\Domain')); + + $eventRule = $base + ->andThat(new HaveNameMatching('*Event')) + ->should(new IsFinal()) + ->because('domain events must be final'); + + $serviceRule = $base + ->andThat(new HaveNameMatching('*Service')) + ->should(new IsNotAbstract()) + ->because('domain services must not be abstract'); + + $eventClass = ClassDescription::getBuilder('App\Domain\UserCreatedEvent', 'src/Domain/UserCreatedEvent.php') + ->build(); + + $eventViolations = new Violations(); + $eventRule->check($eventClass, $eventViolations); + + $serviceViolations = new Violations(); + $serviceRule->check($eventClass, $serviceViolations); + + // eventRule fires (matches namespace + *Event, not final) → 1 violation + self::assertCount(1, $eventViolations); + + // serviceRule does not fire (class name is *Event, not *Service) → 0 violations + self::assertCount(0, $serviceViolations); + } +}