diff --git a/README.md b/README.md index 85b9c1c..c4260cd 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ return PyrameterConfig::create() With `create()`, only the rules you add are used for heavier classifications; all other executed tests stay `unit`. -Use `usesClass()` for a specific class, `usesNamespace()` for a namespace prefix, and `usesFunction()` for a function call that should classify a test as a heavier kind. Function matching is case-insensitive and accepts names with or without a leading backslash. +Use `usesClass()` for a specific class, `usesNamespace()` for a namespace prefix, and `usesFunction()` for a function call that should classify a test as a heavier kind. Names can be configured with or without a leading backslash. To load a config file from another path, pass the `config` parameter to the PHPUnit extension: diff --git a/src/Config/PyrameterConfig.php b/src/Config/PyrameterConfig.php index a43bbc6..fedebed 100644 --- a/src/Config/PyrameterConfig.php +++ b/src/Config/PyrameterConfig.php @@ -5,6 +5,7 @@ namespace Boundwize\Pyrameter\Config; use Boundwize\Pyrameter\Rule\UsageRule; +use Boundwize\Pyrameter\Rule\UsageType; use Boundwize\Pyrameter\TestKind; use InvalidArgumentException; use mysqli; @@ -141,7 +142,7 @@ public function usesNamespace(string $namespace, TestKind $testKind): self public function usesFunction(string $functionName, TestKind $testKind): self { - $this->usageRules[] = new UsageRule(ltrim($functionName, '\\'), $testKind, caseInsensitive: true); + $this->usageRules[] = new UsageRule(ltrim($functionName, '\\'), $testKind, UsageType::Function); return $this; } diff --git a/src/Detection/ConsumedUsageVisitor.php b/src/Detection/ConsumedUsageVisitor.php index 334810f..6e06b4d 100644 --- a/src/Detection/ConsumedUsageVisitor.php +++ b/src/Detection/ConsumedUsageVisitor.php @@ -4,6 +4,7 @@ namespace Boundwize\Pyrameter\Detection; +use Boundwize\Pyrameter\Rule\UsageType; use PhpParser\Node; use PhpParser\Node\Arg; use PhpParser\Node\Attribute; @@ -32,6 +33,7 @@ use function array_keys; use function in_array; use function ltrim; +use function sprintf; use function strtolower; final class ConsumedUsageVisitor extends NodeVisitorAbstract @@ -180,7 +182,7 @@ private function addFunctionName(Name $name): void return; } - $this->consumedUsages[$functionName] = true; + $this->consumedUsages[$this->usageKey(UsageType::Function, $functionName)] = true; } private function addUsage(string $usage): void @@ -191,7 +193,12 @@ private function addUsage(string $usage): void return; } - $this->consumedUsages[$usage] = true; + $this->consumedUsages[$this->usageKey(UsageType::ClassLike, $usage)] = true; + } + + private function usageKey(UsageType $usageType, string $usage): string + { + return sprintf('%s:%s', $usageType->value, $usage); } private function isClassConstant(ClassConstFetch $classConstFetch): bool diff --git a/src/Detection/ScanResult.php b/src/Detection/ScanResult.php index 67513fc..623f90b 100644 --- a/src/Detection/ScanResult.php +++ b/src/Detection/ScanResult.php @@ -7,21 +7,21 @@ final readonly class ScanResult { /** - * @param list $consumedClasses Consumed class, namespace, and function usages. + * @param list $consumedUsages Consumed class-like and function usages. */ private function __construct( public bool $inspectable, - public array $consumedClasses, + public array $consumedUsages, public ?string $errorMessage = null, ) { } /** - * @param list $consumedClasses + * @param list $consumedUsages */ - public static function inspectable(array $consumedClasses): self + public static function inspectable(array $consumedUsages): self { - return new self(true, $consumedClasses); + return new self(true, $consumedUsages); } public static function uninspectable(string $errorMessage): self diff --git a/src/Event/CollectTestResultSubscriber.php b/src/Event/CollectTestResultSubscriber.php index 941a723..d40564d 100644 --- a/src/Event/CollectTestResultSubscriber.php +++ b/src/Event/CollectTestResultSubscriber.php @@ -43,13 +43,13 @@ public function notify(Finished $event): void $kind = $this->kindByTestClassName[$testClassName] ??= $scanResult->inspectable - ? $this->usageClassifier->classify($scanResult->consumedClasses) + ? $this->usageClassifier->classify($scanResult->consumedUsages) : TestKind::Unit; $this->testCollector->add(new TestRecord( testClassName: $testClassName, testMethodName: $testMethodName, - consumedClasses: $scanResult->consumedClasses, + consumedUsages: $scanResult->consumedUsages, kind: $kind, )); } diff --git a/src/Rule/UsageRule.php b/src/Rule/UsageRule.php index 9f6ba09..7450b06 100644 --- a/src/Rule/UsageRule.php +++ b/src/Rule/UsageRule.php @@ -7,41 +7,88 @@ use Boundwize\Pyrameter\TestKind; use function ltrim; +use function sprintf; +use function str_contains; use function str_ends_with; use function str_starts_with; +use function strlen; use function strtolower; +use function substr; final readonly class UsageRule { public function __construct( - public string $classOrNamespace, + public string $usage, public TestKind $kind, - private bool $caseInsensitive = false, + private UsageType $usageType = UsageType::ClassLike, ) { } public function matches(string $consumedUsage): bool { - $configuredUsage = $this->normalize($this->classOrNamespace); - $consumedUsage = $this->normalize($consumedUsage); + $configuredUsage = $this->normalizedUsage(); + [$consumedUsageType, $consumedUsage] = $this->normalizeConsumedUsage($consumedUsage); - if ($consumedUsage === $configuredUsage) { - return true; + if ($consumedUsageType !== $this->usageType) { + return false; } if (! str_ends_with($configuredUsage, '\\')) { - return false; + return $consumedUsage === $configuredUsage; } - return str_starts_with($consumedUsage, $configuredUsage); + return $this->matchesNamespacePrefix($consumedUsage, $configuredUsage); + } + + public function normalizedUsage(): string + { + return $this->normalize($this->usage); + } + + public function isNamespaceRule(): bool + { + return $this->usageType === UsageType::ClassLike && str_ends_with($this->normalizedUsage(), '\\'); + } + + public function normalizedKey(): string + { + return $this->usageKey($this->usageType, $this->normalizedUsage()); } private function normalize(string $usage): string { - if (! $this->caseInsensitive) { - return $usage; + return strtolower(ltrim($usage, '\\')); + } + + private function matchesNamespacePrefix(string $consumedUsage, string $namespaceUsage): bool + { + return str_ends_with($namespaceUsage, '\\') + && strlen($consumedUsage) > strlen($namespaceUsage) + && str_starts_with($consumedUsage, $namespaceUsage); + } + + /** + * @return array{UsageType, string} + */ + private function normalizeConsumedUsage(string $consumedUsage): array + { + if (! str_contains($consumedUsage, ':')) { + return [$this->usageType, $this->normalize($consumedUsage)]; } - return strtolower(ltrim($usage, '\\')); + foreach (UsageType::cases() as $usageType) { + $prefix = $usageType->value . ':'; + + if (str_starts_with($consumedUsage, $prefix)) { + return [$usageType, $this->normalize(substr($consumedUsage, strlen($prefix)))]; + } + } + + return [$this->usageType, $this->normalize($consumedUsage)]; + } + + private function usageKey(UsageType $usageType, string $normalizedUsage): string + { + return sprintf('%s:%s', $usageType->value, $normalizedUsage); } } diff --git a/src/Rule/UsageType.php b/src/Rule/UsageType.php new file mode 100644 index 0000000..a923226 --- /dev/null +++ b/src/Rule/UsageType.php @@ -0,0 +1,11 @@ + $consumedClasses Consumed class, namespace, and function usages. + * @param list $consumedUsages Consumed class-like and function usages. */ public function __construct( public string $testClassName, public string $testMethodName, - public array $consumedClasses, + public array $consumedUsages, public TestKind $kind, ) { } diff --git a/src/UsageClassifier.php b/src/UsageClassifier.php index e8aae75..8bf4136 100644 --- a/src/UsageClassifier.php +++ b/src/UsageClassifier.php @@ -5,40 +5,133 @@ namespace Boundwize\Pyrameter; use Boundwize\Pyrameter\Rule\UsageRule; +use Boundwize\Pyrameter\Rule\UsageType; + +use function ltrim; +use function sprintf; +use function str_contains; +use function str_ends_with; +use function str_starts_with; +use function strlen; +use function strtolower; +use function substr; final readonly class UsageClassifier { + /** @var array */ + private array $exactRules; + + /** @var list */ + private array $namespaceRules; + /** * @param list $rules */ - public function __construct( - private array $rules, - ) { + public function __construct(array $rules) + { + $exactRules = []; + $namespaceRules = []; + + foreach ($rules as $rule) { + if ($rule->isNamespaceRule()) { + $namespaceRules[] = [ + 'usage' => $rule->normalizedKey(), + 'kind' => $rule->kind, + ]; + + continue; + } + + $this->addExactRule($exactRules, $rule->normalizedKey(), $rule->kind); + } + + $this->exactRules = $exactRules; + $this->namespaceRules = $namespaceRules; } /** - * @param list $consumedClasses + * @param list $consumedUsages */ - public function classify(array $consumedClasses): TestKind + public function classify(array $consumedUsages): TestKind { $kind = TestKind::Unit; - foreach ($consumedClasses as $consumedClass) { - foreach ($this->rules as $rule) { - if (! $rule->matches($consumedClass)) { + foreach ($consumedUsages as $consumedUsage) { + $normalizedConsumedUsage = $this->normalizeConsumedUsage($consumedUsage); + + if (isset($this->exactRules[$normalizedConsumedUsage])) { + $kind = $this->heaviest($kind, $this->exactRules[$normalizedConsumedUsage]); + + if ($kind === TestKind::E2E) { + return $kind; + } + } + + foreach ($this->namespaceRules as $namespaceRule) { + if (! $this->matchesNamespaceRule($normalizedConsumedUsage, $namespaceRule['usage'])) { continue; } - if ($rule->kind->weight() > $kind->weight()) { - $kind = $rule->kind; + $kind = $this->heaviest($kind, $namespaceRule['kind']); - if ($kind === TestKind::E2E) { - return $kind; - } + if ($kind === TestKind::E2E) { + return $kind; } } } return $kind; } + + /** + * @param array $rules + */ + private function addExactRule(array &$rules, string $usage, TestKind $testKind): void + { + $rules[$usage] = isset($rules[$usage]) + ? $this->heaviest($rules[$usage], $testKind) + : $testKind; + } + + private function heaviest(TestKind $left, TestKind $right): TestKind + { + return $right->weight() > $left->weight() ? $right : $left; + } + + private function matchesNamespaceRule(string $consumedUsage, string $namespaceUsage): bool + { + return str_ends_with($namespaceUsage, '\\') + && strlen($consumedUsage) > strlen($namespaceUsage) + && str_starts_with($consumedUsage, $namespaceUsage); + } + + private function normalizeConsumedUsage(string $consumedUsage): string + { + if (! str_contains($consumedUsage, ':')) { + return $this->usageKey(UsageType::ClassLike, $this->normalizeUsageName($consumedUsage)); + } + + foreach (UsageType::cases() as $usageType) { + $prefix = $usageType->value . ':'; + + if (str_starts_with($consumedUsage, $prefix)) { + return $this->usageKey( + $usageType, + $this->normalizeUsageName(substr($consumedUsage, strlen($prefix))) + ); + } + } + + return $this->usageKey(UsageType::ClassLike, $this->normalizeUsageName($consumedUsage)); + } + + private function normalizeUsageName(string $usageName): string + { + return strtolower(ltrim($usageName, '\\')); + } + + private function usageKey(UsageType $usageType, string $normalizedUsage): string + { + return sprintf('%s:%s', $usageType->value, $normalizedUsage); + } } diff --git a/tests/Config/PyrameterConfigTest.php b/tests/Config/PyrameterConfigTest.php index 95b9f3a..0c97ea2 100644 --- a/tests/Config/PyrameterConfigTest.php +++ b/tests/Config/PyrameterConfigTest.php @@ -8,8 +8,11 @@ use Boundwize\Pyrameter\TestKind; use Boundwize\Pyrameter\UsageClassifier; use InvalidArgumentException; +use PDO; use PHPUnit\Framework\TestCase; +use function strtolower; + final class PyrameterConfigTest extends TestCase { public function testTargetShapePercentagesMustNotBeNegative(): void @@ -51,11 +54,45 @@ public function testUsesNamespaceNormalizesTrailingBackslash(): void $this->assertSame(TestKind::E2E, $usageClassifier->classify(['App\Tests\Browser\Checkout'])); } + public function testUsesNamespaceDoesNotMatchPartialNamespaceSegment(): void + { + $pyrameterConfig = PyrameterConfig::create()->usesNamespace('App', TestKind::Functional); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame(TestKind::Functional, $usageClassifier->classify(['App\Foo'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['Application\Foo'])); + } + + public function testUsesNamespaceWithTrailingBackslashDoesNotMatchPartialNamespaceSegment(): void + { + $pyrameterConfig = PyrameterConfig::create()->usesNamespace('App\\', TestKind::Functional); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame(TestKind::Functional, $usageClassifier->classify(['App\Foo'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['Application\Foo'])); + } + + public function testUsesClassMatchesClassUsageCaseInsensitively(): void + { + $pyrameterConfig = PyrameterConfig::create()->usesClass(PDO::class, TestKind::Integration); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['\\' . strtolower(PDO::class)])); + } + + public function testUsesNamespaceMatchesNamespaceUsageCaseInsensitively(): void + { + $pyrameterConfig = PyrameterConfig::create()->usesNamespace('App\Tests\Browser', TestKind::E2E); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['\aPp\tEsTs\bRoWsEr\Checkout'])); + } + public function testUsesFunctionMatchesFunctionUsageCaseInsensitively(): void { $pyrameterConfig = PyrameterConfig::create()->usesFunction('file_get_contents', TestKind::Integration); $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); - $this->assertSame(TestKind::Integration, $usageClassifier->classify(['FILE_GET_CONTENTS'])); + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['function:\FILE_GET_CONTENTS'])); } } diff --git a/tests/Detection/ConsumedUsageExtractorTest.php b/tests/Detection/ConsumedUsageExtractorTest.php index e802416..d87f728 100644 --- a/tests/Detection/ConsumedUsageExtractorTest.php +++ b/tests/Detection/ConsumedUsageExtractorTest.php @@ -62,27 +62,27 @@ interface ChildInterface extends \Vendor\Contracts\ParentA, \Vendor\Contracts\Pa sort($usages); $this->assertSame([ - 'Vendor\Attributes\ExampleAttribute', - 'Vendor\BaseClass', - 'Vendor\Constructed\Thing', - 'Vendor\Contracts\FirstContract', - 'Vendor\Contracts\ParentA', - 'Vendor\Contracts\ParentB', - 'Vendor\Contracts\SecondContract', - 'Vendor\Grouped\AnotherGroupedClass', - 'Vendor\Grouped\GroupedClass', - 'Vendor\ImportedThing', - 'Vendor\Params\Input', - 'Vendor\Returns\Output', - 'Vendor\StaticCall\Thing', - 'Vendor\StaticProperty\Thing', - 'Vendor\Traits\FirstTrait', - 'Vendor\Traits\SecondTrait', - 'Vendor\Types\IntersectionA', - 'Vendor\Types\IntersectionB', - 'Vendor\Types\NullableType', - 'Vendor\Types\UnionA', - 'Vendor\Types\UnionB', + 'class:Vendor\Attributes\ExampleAttribute', + 'class:Vendor\BaseClass', + 'class:Vendor\Constructed\Thing', + 'class:Vendor\Contracts\FirstContract', + 'class:Vendor\Contracts\ParentA', + 'class:Vendor\Contracts\ParentB', + 'class:Vendor\Contracts\SecondContract', + 'class:Vendor\Grouped\AnotherGroupedClass', + 'class:Vendor\Grouped\GroupedClass', + 'class:Vendor\ImportedThing', + 'class:Vendor\Params\Input', + 'class:Vendor\Returns\Output', + 'class:Vendor\StaticCall\Thing', + 'class:Vendor\StaticProperty\Thing', + 'class:Vendor\Traits\FirstTrait', + 'class:Vendor\Traits\SecondTrait', + 'class:Vendor\Types\IntersectionA', + 'class:Vendor\Types\IntersectionB', + 'class:Vendor\Types\NullableType', + 'class:Vendor\Types\UnionA', + 'class:Vendor\Types\UnionB', ], $usages); } @@ -96,7 +96,7 @@ public function testItIgnoresNonClassImportsWithinAMixedGroupUse(): void new GroupedClass(); PHP); - $this->assertSame(['Vendor\MixedGroup\GroupedClass'], $usages); + $this->assertSame(['class:Vendor\MixedGroup\GroupedClass'], $usages); } public function testItIgnoresUnusedImports(): void @@ -110,7 +110,7 @@ public function testItIgnoresUnusedImports(): void new UsedThing(); PHP); - $this->assertSame(['Vendor\Used\Thing'], $usages); + $this->assertSame(['class:Vendor\Used\Thing'], $usages); } public function testItExtractsClassesFromInstanceofAndCatchTypes(): void @@ -143,11 +143,11 @@ public function method(object $value, string $className): void sort($usages); $this->assertSame([ - 'Vendor\Checks\DirectCheck', - 'Vendor\Checks\ImportedCheck', - 'Vendor\Exceptions\AliasedException', - 'Vendor\Exceptions\FirstException', - 'Vendor\Exceptions\SecondException', + 'class:Vendor\Checks\DirectCheck', + 'class:Vendor\Checks\ImportedCheck', + 'class:Vendor\Exceptions\AliasedException', + 'class:Vendor\Exceptions\FirstException', + 'class:Vendor\Exceptions\SecondException', ], $usages); } @@ -172,11 +172,11 @@ function_call(\Vendor\ClassConstant\FunctionArgument::class); sort($usages); $this->assertSame([ - 'Vendor\ClassConstant\Direct', - 'Vendor\ClassConstant\DynamicMethod', - 'Vendor\ClassConstant\FunctionArgument', - 'Vendor\ClassConstant\NotFirstArgument', - 'function_call', + 'class:Vendor\ClassConstant\Direct', + 'class:Vendor\ClassConstant\DynamicMethod', + 'class:Vendor\ClassConstant\FunctionArgument', + 'class:Vendor\ClassConstant\NotFirstArgument', + 'function:function_call', ], $usages); } @@ -206,9 +206,9 @@ public function method(): void sort($usages); $this->assertSame([ - 'file_get_contents', - 'file_put_contents', - 'vendor\filesystem\readfixture', + 'function:file_get_contents', + 'function:file_put_contents', + 'function:vendor\filesystem\readfixture', ], $usages); } @@ -230,8 +230,8 @@ public function testItResetsConsumedUsagesBetweenExtractions(): void $this->assertIsArray($firstNodes); $this->assertIsArray($secondNodes); - $this->assertSame(['Vendor\FirstUsage'], $consumedUsageExtractor->extract(array_values($firstNodes))); - $this->assertSame(['Vendor\SecondUsage'], $consumedUsageExtractor->extract(array_values($secondNodes))); + $this->assertSame(['class:Vendor\FirstUsage'], $consumedUsageExtractor->extract(array_values($firstNodes))); + $this->assertSame(['class:Vendor\SecondUsage'], $consumedUsageExtractor->extract(array_values($secondNodes))); } public function testItIgnoresEmptyFunctionNames(): void diff --git a/tests/Rule/UsageRuleTest.php b/tests/Rule/UsageRuleTest.php new file mode 100644 index 0000000..6863fb8 --- /dev/null +++ b/tests/Rule/UsageRuleTest.php @@ -0,0 +1,64 @@ +assertTrue($usageRule->matches('App\Service')); + } + + public function testExactUsageDoesNotMatchPrefixes(): void + { + $usageRule = new UsageRule('App\Service', TestKind::Integration); + + $this->assertFalse($usageRule->matches('App\Service\Child')); + } + + public function testItMatchesNamespacePrefixes(): void + { + $usageRule = new UsageRule('App\\', TestKind::Functional); + + $this->assertTrue($usageRule->matches('App\Service')); + $this->assertFalse($usageRule->matches('App\\')); + $this->assertFalse($usageRule->matches('Application\Service')); + $this->assertFalse($usageRule->matches('Library\Service')); + } + + public function testItNormalizesUsageCaseAndLeadingSlash(): void + { + $usageRule = new UsageRule('\file_get_contents', TestKind::Integration, UsageType::Function); + + $this->assertTrue($usageRule->matches('function:\FILE_GET_CONTENTS')); + } + + public function testItDoesNotMatchDifferentUsageTypes(): void + { + $usageRule = new UsageRule('file_get_contents', TestKind::Integration, UsageType::Function); + + $this->assertFalse($usageRule->matches('class:file_get_contents')); + } + + public function testItTreatsUnknownTypedConsumedUsageAsRawUsage(): void + { + $usageRule = new UsageRule('custom:file_get_contents', TestKind::Integration); + + $this->assertTrue($usageRule->matches('custom:FILE_GET_CONTENTS')); + } + + public function testUsageTypesExposeStablePrefixes(): void + { + $this->assertSame('class', UsageType::ClassLike->value); + $this->assertSame('function', UsageType::Function->value); + } +} diff --git a/tests/UsageClassificationTest.php b/tests/UsageClassificationTest.php index 9a50f84..c9f553e 100644 --- a/tests/UsageClassificationTest.php +++ b/tests/UsageClassificationTest.php @@ -5,6 +5,7 @@ namespace Boundwize\Pyrameter\Tests; use Boundwize\Pyrameter\Config\PyrameterConfig; +use Boundwize\Pyrameter\Detection\ConsumedUsageExtractor; use Boundwize\Pyrameter\Detection\TestUsageScanner; use Boundwize\Pyrameter\TestKind; use Boundwize\Pyrameter\Tests\Fixtures\ContainerGetHeavyFixture; @@ -21,9 +22,12 @@ use Boundwize\Pyrameter\Tests\Fixtures\SymfonyFunctionalFixture; use Boundwize\Pyrameter\Tests\Fixtures\WebDriverE2EFixture; use Boundwize\Pyrameter\UsageClassifier; +use PhpParser\ParserFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function array_values; + final class UsageClassificationTest extends TestCase { /** @@ -38,7 +42,7 @@ public function testItClassifiesUsageFromTheTestFile(string $fixtureClass, TestK $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); $this->assertTrue($scanResult->inspectable, $scanResult->errorMessage ?? ''); - $this->assertSame($testKind, $usageClassifier->classify($scanResult->consumedClasses)); + $this->assertSame($testKind, $usageClassifier->classify($scanResult->consumedUsages)); } /** @@ -69,7 +73,7 @@ public function testMockTargetsAreRemovedFromConsumedClasses(): void $scanResult = (new TestUsageScanner())->scan(MockedHeavyFixture::class); $this->assertTrue($scanResult->inspectable); - $this->assertNotContains('PDO', $scanResult->consumedClasses); + $this->assertNotContains('class:PDO', $scanResult->consumedUsages); } /** @@ -103,7 +107,7 @@ public function testDefaultRulesClassifyFileOperationFunctionsAsIntegration(stri $pyrameterConfig = PyrameterConfig::defaults(); $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); - $this->assertSame(TestKind::Integration, $usageClassifier->classify([$functionName])); + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['function:' . $functionName])); } /** @@ -179,7 +183,61 @@ public function testUninspectableTestHasNoConsumedClasses(): void $scanResult = (new TestUsageScanner())->scan('Boundwize\Pyrameter\Tests\Fixtures\MissingFixture'); $this->assertFalse($scanResult->inspectable); - $this->assertSame([], $scanResult->consumedClasses); + $this->assertSame([], $scanResult->consumedUsages); $this->assertNotNull($scanResult->errorMessage); } + + public function testClassNamedLikeFileOperationFunctionStaysUnit(): void + { + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $nodes = $parser->parse(<<<'PHP' +assertInstanceOf('file_get_contents', new file_get_contents()); + } +} +PHP); + + $this->assertIsArray($nodes); + + $consumedUsages = (new ConsumedUsageExtractor())->extract(array_values($nodes)); + $pyrameterConfig = PyrameterConfig::defaults(); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame(['class:file_get_contents'], $consumedUsages); + $this->assertSame(TestKind::Unit, $usageClassifier->classify($consumedUsages)); + } + + public function testClassNamedLikeFileOperationFunctionDeclaredElsewhereStaysUnit(): void + { + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $nodes = $parser->parse(<<<'PHP' +assertInstanceOf('file_get_contents', new file_get_contents()); + } +} +PHP); + + $this->assertIsArray($nodes); + + $consumedUsages = (new ConsumedUsageExtractor())->extract(array_values($nodes)); + $pyrameterConfig = PyrameterConfig::defaults(); + $usageClassifier = new UsageClassifier($pyrameterConfig->usageRules()); + + $this->assertSame(['class:file_get_contents'], $consumedUsages); + $this->assertSame(TestKind::Unit, $usageClassifier->classify($consumedUsages)); + } } diff --git a/tests/UsageClassifierTest.php b/tests/UsageClassifierTest.php new file mode 100644 index 0000000..403814f --- /dev/null +++ b/tests/UsageClassifierTest.php @@ -0,0 +1,115 @@ +assertSame(TestKind::E2E, $usageClassifier->classify(['App\Tests\Browser\CheckoutTest'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['class:App\Tests\Browser\\'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['App\Tests\Browsering\CheckoutTest'])); + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['app\Tests\Browser\CheckoutTest'])); + } + + public function testExactRulesDoNotMatchNamespacePrefixes(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('App\Tests\Browser', TestKind::E2E), + ]); + + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['App\Tests\Browser'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['App\Tests\Browser\CheckoutTest'])); + } + + public function testNamespaceRulesNormalizeConfiguredAndConsumedUsage(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('App\Tests\Browser\\', TestKind::E2E), + ]); + + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['\\APP\Tests\Browser\CheckoutTest'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['\\APP\Tests\Browsering\CheckoutTest'])); + } + + public function testNamespaceRulesRespectNamespaceSeparators(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('App\\', TestKind::Functional), + ]); + + $this->assertSame(TestKind::Functional, $usageClassifier->classify(['App\Service'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['Application\Service'])); + } + + public function testExactRulesNormalizeConfiguredAndConsumedUsageButDoNotMatchPrefixes(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('file_get_contents', TestKind::Integration, UsageType::Function), + ]); + + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['function:FILE_GET_CONTENTS'])); + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['function:FILE_GET_CONTENTS_EXTRA'])); + } + + public function testFunctionRulesDoNotMatchClassLikeUsages(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('file_get_contents', TestKind::Integration, UsageType::Function), + ]); + + $this->assertSame(TestKind::Unit, $usageClassifier->classify(['class:file_get_contents'])); + } + + public function testUnknownTypedConsumedUsageFallsBackToClassLikeMatching(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('custom:file_get_contents', TestKind::Integration), + ]); + + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['custom:FILE_GET_CONTENTS'])); + } + + public function testExactRulesCanShortCircuitOnE2EAfterNormalization(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('run_browser_session', TestKind::E2E, UsageType::Function), + new UsageRule('file_get_contents', TestKind::Integration, UsageType::Function), + ]); + + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['function:RUN_BROWSER_SESSION'])); + } + + public function testDuplicateExactRulesUseTheHeaviestKind(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('App\Service', TestKind::Functional), + new UsageRule('App\Service', TestKind::Integration), + ]); + + $this->assertSame(TestKind::Integration, $usageClassifier->classify(['App\Service'])); + } + + public function testHeaviestMatchingRuleWinsAfterRulesArePrecompiled(): void + { + $usageClassifier = new UsageClassifier([ + new UsageRule('App\\', TestKind::Functional), + new UsageRule('App\Tests\\', TestKind::Integration), + new UsageRule('App\Tests\Browser\\', TestKind::E2E), + ]); + + $this->assertSame(TestKind::E2E, $usageClassifier->classify(['App\Tests\Browser\CheckoutTest'])); + } +}