From 24b41432f79e2e5e91ac168c76617c5d69f263e0 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Fri, 13 Mar 2026 19:32:29 +0100 Subject: [PATCH] Initial work on class-oriented HTML report --- src/Node/File.php | 28 ++ src/Report/Html/ClassView/Builder.php | 229 ++++++++++ src/Report/Html/ClassView/Node/ClassNode.php | 340 +++++++++++++++ .../Html/ClassView/Node/NamespaceNode.php | 397 ++++++++++++++++++ .../Html/ClassView/Node/ParentSection.php | 50 +++ .../Html/ClassView/Node/TraitSection.php | 49 +++ src/Report/Html/ClassView/Renderer/Class_.php | 386 +++++++++++++++++ .../Html/ClassView/Renderer/Dashboard.php | 353 ++++++++++++++++ .../Html/ClassView/Renderer/Namespace_.php | 222 ++++++++++ src/Report/Html/Facade.php | 113 ++++- src/Report/Html/Renderer.php | 363 +++++++++++++++- src/Report/Html/Renderer/File.php | 312 -------------- .../Html/Renderer/Template/class.html.dist | 64 +++ .../Renderer/Template/class_branch.html.dist | 66 +++ .../Renderer/Template/class_item.html.dist | 14 + .../Template/class_item_branch.html.dist | 20 + .../Renderer/Template/dashboard.html.dist | 1 + .../Template/dashboard_branch.html.dist | 1 + .../Renderer/Template/directory.html.dist | 1 + .../Template/directory_branch.html.dist | 1 + .../Html/Renderer/Template/file.html.dist | 1 + .../Renderer/Template/file_branch.html.dist | 1 + .../Renderer/Template/namespace.html.dist | 61 +++ .../Template/namespace_branch.html.dist | 63 +++ .../Template/namespace_item.html.dist | 13 + .../Template/namespace_item_branch.html.dist | 19 + .../Template/section_header.html.dist | 1 + tests/_files/ClassView/ChildClass.php | 13 + tests/_files/ClassView/ExampleClass.php | 16 + tests/_files/ClassView/ExampleTrait.php | 11 + tests/_files/ClassView/ParentClass.php | 11 + .../BankAccount.php.html | 5 + .../_classes/BankAccount.html | 225 ++++++++++ .../_classes/dashboard.html | 310 ++++++++++++++ .../_classes/index.html | 123 ++++++ .../CoverageForBankAccount/dashboard.html | 5 + .../HTML/CoverageForBankAccount/index.html | 5 + ...ssWithAnonymousFunctionInStaticMethod.html | 143 +++++++ .../_classes/dashboard.html | 306 ++++++++++++++ .../_classes/index.html | 123 ++++++ .../dashboard.html | 5 + .../index.html | 5 + ...with_class_and_anonymous_function.php.html | 5 + .../_classes/Bar.html | 110 +++++ .../_classes/Foo.html | 107 +++++ .../_classes/dashboard.html | 306 ++++++++++++++ .../_classes/index.html | 106 +++++ .../dashboard.html | 5 + .../index.html | 5 + .../source_with_ignore.php.html | 5 + .../BankAccount.php.html | 5 + .../BankAccount.php_branch.html | 5 + .../BankAccount.php_path.html | 5 + .../_classes/BankAccount.html | 307 ++++++++++++++ .../_classes/dashboard.html | 304 ++++++++++++++ .../_classes/index.html | 157 +++++++ .../PathCoverageForBankAccount/dashboard.html | 5 + .../PathCoverageForBankAccount/index.html | 5 + .../_classes/Foo.html | 100 +++++ .../_classes/dashboard.html | 300 +++++++++++++ .../_classes/index.html | 107 +++++ .../dashboard.html | 5 + .../index.html | 5 + .../source_without_namespace.php.html | 5 + .../source_without_namespace.php_branch.html | 5 + .../source_without_namespace.php_path.html | 5 + .../Report/Html/ClassView/BuilderTest.php | 308 ++++++++++++++ .../Html/ClassView/Node/ClassNodeTest.php | 378 +++++++++++++++++ .../Html/ClassView/Node/NamespaceNodeTest.php | 356 ++++++++++++++++ .../Html/ClassView/Node/ParentSectionTest.php | 38 ++ .../Html/ClassView/Node/TraitSectionTest.php | 42 ++ tests/tests/Report/Html/EndToEndTest.php | 21 +- 72 files changed, 7251 insertions(+), 341 deletions(-) create mode 100644 src/Report/Html/ClassView/Builder.php create mode 100644 src/Report/Html/ClassView/Node/ClassNode.php create mode 100644 src/Report/Html/ClassView/Node/NamespaceNode.php create mode 100644 src/Report/Html/ClassView/Node/ParentSection.php create mode 100644 src/Report/Html/ClassView/Node/TraitSection.php create mode 100644 src/Report/Html/ClassView/Renderer/Class_.php create mode 100644 src/Report/Html/ClassView/Renderer/Dashboard.php create mode 100644 src/Report/Html/ClassView/Renderer/Namespace_.php create mode 100644 src/Report/Html/Renderer/Template/class.html.dist create mode 100644 src/Report/Html/Renderer/Template/class_branch.html.dist create mode 100644 src/Report/Html/Renderer/Template/class_item.html.dist create mode 100644 src/Report/Html/Renderer/Template/class_item_branch.html.dist create mode 100644 src/Report/Html/Renderer/Template/namespace.html.dist create mode 100644 src/Report/Html/Renderer/Template/namespace_branch.html.dist create mode 100644 src/Report/Html/Renderer/Template/namespace_item.html.dist create mode 100644 src/Report/Html/Renderer/Template/namespace_item_branch.html.dist create mode 100644 src/Report/Html/Renderer/Template/section_header.html.dist create mode 100644 tests/_files/ClassView/ChildClass.php create mode 100644 tests/_files/ClassView/ExampleClass.php create mode 100644 tests/_files/ClassView/ExampleTrait.php create mode 100644 tests/_files/ClassView/ParentClass.php create mode 100644 tests/_files/Report/HTML/CoverageForBankAccount/_classes/BankAccount.html create mode 100644 tests/_files/Report/HTML/CoverageForBankAccount/_classes/dashboard.html create mode 100644 tests/_files/Report/HTML/CoverageForBankAccount/_classes/index.html create mode 100644 tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/CoveredClassWithAnonymousFunctionInStaticMethod.html create mode 100644 tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html create mode 100644 tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/index.html create mode 100644 tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Bar.html create mode 100644 tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Foo.html create mode 100644 tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/dashboard.html create mode 100644 tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/index.html create mode 100644 tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/BankAccount.html create mode 100644 tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html create mode 100644 tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/index.html create mode 100644 tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html create mode 100644 tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html create mode 100644 tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/index.html create mode 100644 tests/tests/Report/Html/ClassView/BuilderTest.php create mode 100644 tests/tests/Report/Html/ClassView/Node/ClassNodeTest.php create mode 100644 tests/tests/Report/Html/ClassView/Node/NamespaceNodeTest.php create mode 100644 tests/tests/Report/Html/ClassView/Node/ParentSectionTest.php create mode 100644 tests/tests/Report/Html/ClassView/Node/TraitSectionTest.php diff --git a/src/Node/File.php b/src/Node/File.php index 5b4a63c44..df1c68aa7 100644 --- a/src/Node/File.php +++ b/src/Node/File.php @@ -86,6 +86,16 @@ final class File extends AbstractNode */ private array $codeUnitsByLine = []; + /** + * @var array + */ + private readonly array $rawClasses; + + /** + * @var array + */ + private readonly array $rawTraits; + /** * @param non-empty-string $sha1 * @param array> $lineCoverageData @@ -103,6 +113,8 @@ public function __construct(string $name, AbstractNode $parent, string $sha1, ar $this->functionCoverageData = $functionCoverageData; $this->testData = $testData; $this->linesOfCode = $linesOfCode; + $this->rawClasses = $classes; + $this->rawTraits = $traits; $this->calculateStatistics($classes, $traits, $functions); } @@ -141,6 +153,22 @@ public function testData(): array return $this->testData; } + /** + * @return array + */ + public function rawClasses(): array + { + return $this->rawClasses; + } + + /** + * @return array + */ + public function rawTraits(): array + { + return $this->rawTraits; + } + /** * @return array */ diff --git a/src/Report/Html/ClassView/Builder.php b/src/Report/Html/ClassView/Builder.php new file mode 100644 index 000000000..9b447b40e --- /dev/null +++ b/src/Report/Html/ClassView/Builder.php @@ -0,0 +1,229 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView; + +use function array_key_exists; +use function array_keys; +use function count; +use function explode; +use function in_array; +use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; +use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode; +use SebastianBergmann\CodeCoverage\Node\File as FileNode; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\ClassNode; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\NamespaceNode; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\ParentSection; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\TraitSection; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Class_; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Trait_; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class Builder +{ + /** + * @var array + */ + private array $classRegistry = []; + + /** + * @var array + */ + private array $traitRegistry = []; + + public function build(DirectoryNode $root): NamespaceNode + { + $this->classRegistry = []; + $this->traitRegistry = []; + + $this->collectRegistries($root); + + $rootNamespace = new NamespaceNode('(Global)', ''); + + /** @var array $namespaceMap */ + $namespaceMap = ['' => $rootNamespace]; + + foreach ($this->classRegistry as $fqcn => $entry) { + $raw = $entry['raw']; + $namespace = $raw->namespace(); + + $parentNs = $this->ensureNamespaceExists($namespace, $namespaceMap, $rootNamespace); + + $traitSections = $this->resolveTraits($raw); + $parentSections = $this->resolveParents($raw); + + $classNode = new ClassNode( + $fqcn, + $namespace, + $entry['file']->pathAsString(), + $raw->startLine(), + $raw->endLine(), + $entry['processed'], + $entry['file'], + $traitSections, + $parentSections, + $parentNs, + ); + + $parentNs->addClass($classNode); + } + + return $this->reduceRoot($rootNamespace); + } + + private function reduceRoot(NamespaceNode $root): NamespaceNode + { + while (count($root->childNamespaces()) === 1 && count($root->classes()) === 0) { + $root = $root->childNamespaces()[0]; + } + + $root->promoteToRoot(); + + return $root; + } + + private function collectRegistries(DirectoryNode $directory): void + { + foreach ($directory as $node) { + if ($node instanceof DirectoryNode) { + continue; + } + + if (!$node instanceof FileNode) { + continue; + } + + foreach ($node->rawClasses() as $className => $rawClass) { + if (array_key_exists($className, $node->classes())) { + $this->classRegistry[$className] = [ + 'file' => $node, + 'raw' => $rawClass, + 'processed' => $node->classes()[$className], + ]; + } + } + + foreach ($node->rawTraits() as $traitName => $rawTrait) { + if (array_key_exists($traitName, $node->traits())) { + $this->traitRegistry[$traitName] = [ + 'file' => $node, + 'raw' => $rawTrait, + 'processed' => $node->traits()[$traitName], + ]; + } + } + } + } + + /** + * @param array $namespaceMap + */ + private function ensureNamespaceExists(string $namespace, array &$namespaceMap, NamespaceNode $rootNamespace): NamespaceNode + { + if (isset($namespaceMap[$namespace])) { + return $namespaceMap[$namespace]; + } + + $parts = explode('\\', $namespace); + $current = ''; + + $parentNode = $rootNamespace; + + foreach ($parts as $part) { + $current = $current === '' ? $part : $current . '\\' . $part; + + if (!isset($namespaceMap[$current])) { + $node = new NamespaceNode($part, $current, $parentNode); + $parentNode->addNamespace($node); + $namespaceMap[$current] = $node; + } + + $parentNode = $namespaceMap[$current]; + } + + return $parentNode; + } + + /** + * @return list + */ + private function resolveTraits(Class_ $class): array + { + $sections = []; + + foreach ($class->traits() as $traitName) { + if (!isset($this->traitRegistry[$traitName])) { + continue; + } + + $entry = $this->traitRegistry[$traitName]; + + $sections[] = new TraitSection( + $traitName, + $entry['file']->pathAsString(), + $entry['raw']->startLine(), + $entry['raw']->endLine(), + $entry['processed'], + $entry['file'], + ); + } + + return $sections; + } + + /** + * @return list + */ + private function resolveParents(Class_ $class): array + { + $sections = []; + $ownMethods = array_keys($class->methods()); + $seenMethods = $ownMethods; + $currentClass = $class; + + while ($currentClass->hasParent()) { + $parentName = $currentClass->parentClass(); + + if ($parentName === null || !isset($this->classRegistry[$parentName])) { + break; + } + + $parentEntry = $this->classRegistry[$parentName]; + $parentRaw = $parentEntry['raw']; + $parentProcessed = $parentEntry['processed']; + + $inheritedMethods = []; + + foreach ($parentProcessed->methods as $methodName => $method) { + if (!in_array($methodName, $seenMethods, true)) { + $inheritedMethods[$methodName] = $method; + $seenMethods[] = $methodName; + } + } + + if ($inheritedMethods !== []) { + $sections[] = new ParentSection( + $parentName, + $parentEntry['file']->pathAsString(), + $inheritedMethods, + $parentEntry['file'], + ); + } + + $currentClass = $parentRaw; + } + + return $sections; + } +} diff --git a/src/Report/Html/ClassView/Node/ClassNode.php b/src/Report/Html/ClassView/Node/ClassNode.php new file mode 100644 index 000000000..9203c4e15 --- /dev/null +++ b/src/Report/Html/ClassView/Node/ClassNode.php @@ -0,0 +1,340 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node; + +use function count; +use function explode; +use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\Node\File as FileNode; +use SebastianBergmann\CodeCoverage\Util\Percentage; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class ClassNode +{ + /** + * @var non-empty-string + */ + private readonly string $className; + private readonly string $namespace; + + /** + * @var non-empty-string + */ + private readonly string $filePath; + private readonly int $startLine; + private readonly int $endLine; + private readonly ProcessedClassType $class; + private readonly FileNode $fileNode; + + /** + * @var list + */ + private readonly array $traitSections; + + /** + * @var list + */ + private readonly array $parentSections; + private readonly NamespaceNode $parent; + private ?int $numMethods = null; + private ?int $numTestedMethods = null; + + /** + * @param non-empty-string $className + * @param non-empty-string $filePath + * @param list $traitSections + * @param list $parentSections + */ + public function __construct(string $className, string $namespace, string $filePath, int $startLine, int $endLine, ProcessedClassType $class, FileNode $fileNode, array $traitSections, array $parentSections, NamespaceNode $parent) + { + $this->className = $className; + $this->namespace = $namespace; + $this->filePath = $filePath; + $this->startLine = $startLine; + $this->endLine = $endLine; + $this->class = $class; + $this->fileNode = $fileNode; + $this->traitSections = $traitSections; + $this->parentSections = $parentSections; + $this->parent = $parent; + } + + /** + * @return non-empty-string + */ + public function className(): string + { + return $this->className; + } + + public function shortName(): string + { + $parts = explode('\\', $this->className); + + return $parts[count($parts) - 1]; + } + + public function namespace(): string + { + return $this->namespace; + } + + /** + * @return non-empty-string + */ + public function filePath(): string + { + return $this->filePath; + } + + public function startLine(): int + { + return $this->startLine; + } + + public function endLine(): int + { + return $this->endLine; + } + + public function class_(): ProcessedClassType + { + return $this->class; + } + + public function fileNode(): FileNode + { + return $this->fileNode; + } + + /** + * @return list + */ + public function traitSections(): array + { + return $this->traitSections; + } + + /** + * @return list + */ + public function parentSections(): array + { + return $this->parentSections; + } + + public function parent(): NamespaceNode + { + return $this->parent; + } + + /** + * @return array + */ + public function allMethods(): array + { + $methods = $this->class->methods; + + foreach ($this->traitSections as $section) { + foreach ($section->trait->methods as $name => $method) { + $methods['[' . $section->traitName . '] ' . $name] = $method; + } + } + + foreach ($this->parentSections as $section) { + foreach ($section->methods as $name => $method) { + $methods['[' . $section->className . '] ' . $name] = $method; + } + } + + return $methods; + } + + public function numberOfExecutableLines(): int + { + $lines = $this->class->executableLines; + + foreach ($this->traitSections as $section) { + $lines += $section->trait->executableLines; + } + + foreach ($this->parentSections as $section) { + foreach ($section->methods as $method) { + $lines += $method->executableLines; + } + } + + return $lines; + } + + public function numberOfExecutedLines(): int + { + $lines = $this->class->executedLines; + + foreach ($this->traitSections as $section) { + $lines += $section->trait->executedLines; + } + + foreach ($this->parentSections as $section) { + foreach ($section->methods as $method) { + $lines += $method->executedLines; + } + } + + return $lines; + } + + public function numberOfExecutableBranches(): int + { + $branches = $this->class->executableBranches; + + foreach ($this->traitSections as $section) { + $branches += $section->trait->executableBranches; + } + + foreach ($this->parentSections as $section) { + foreach ($section->methods as $method) { + $branches += $method->executableBranches; + } + } + + return $branches; + } + + public function numberOfExecutedBranches(): int + { + $branches = $this->class->executedBranches; + + foreach ($this->traitSections as $section) { + $branches += $section->trait->executedBranches; + } + + foreach ($this->parentSections as $section) { + foreach ($section->methods as $method) { + $branches += $method->executedBranches; + } + } + + return $branches; + } + + public function numberOfExecutablePaths(): int + { + $paths = $this->class->executablePaths; + + foreach ($this->traitSections as $section) { + $paths += $section->trait->executablePaths; + } + + foreach ($this->parentSections as $section) { + foreach ($section->methods as $method) { + $paths += $method->executablePaths; + } + } + + return $paths; + } + + public function numberOfExecutedPaths(): int + { + $paths = $this->class->executedPaths; + + foreach ($this->traitSections as $section) { + $paths += $section->trait->executedPaths; + } + + foreach ($this->parentSections as $section) { + foreach ($section->methods as $method) { + $paths += $method->executedPaths; + } + } + + return $paths; + } + + public function numberOfMethods(): int + { + if ($this->numMethods === null) { + $this->numMethods = 0; + + foreach ($this->allMethods() as $method) { + if ($method->executableLines > 0) { + $this->numMethods++; + } + } + } + + return $this->numMethods; + } + + public function numberOfTestedMethods(): int + { + if ($this->numTestedMethods === null) { + $this->numTestedMethods = 0; + + foreach ($this->allMethods() as $method) { + if ($method->executableLines > 0 && $method->coverage === 100) { + $this->numTestedMethods++; + } + } + } + + return $this->numTestedMethods; + } + + public function percentageOfExecutedLines(): Percentage + { + return Percentage::fromFractionAndTotal( + $this->numberOfExecutedLines(), + $this->numberOfExecutableLines(), + ); + } + + public function percentageOfExecutedBranches(): Percentage + { + return Percentage::fromFractionAndTotal( + $this->numberOfExecutedBranches(), + $this->numberOfExecutableBranches(), + ); + } + + public function percentageOfExecutedPaths(): Percentage + { + return Percentage::fromFractionAndTotal( + $this->numberOfExecutedPaths(), + $this->numberOfExecutablePaths(), + ); + } + + public function percentageOfTestedMethods(): Percentage + { + return Percentage::fromFractionAndTotal( + $this->numberOfTestedMethods(), + $this->numberOfMethods(), + ); + } + + public function percentageOfTestedClasses(): Percentage + { + if ($this->numberOfMethods() === 0) { + return Percentage::fromFractionAndTotal(0, 0); + } + + return Percentage::fromFractionAndTotal( + $this->numberOfTestedMethods() === $this->numberOfMethods() ? 1 : 0, + 1, + ); + } +} diff --git a/src/Report/Html/ClassView/Node/NamespaceNode.php b/src/Report/Html/ClassView/Node/NamespaceNode.php new file mode 100644 index 000000000..8e9d43d15 --- /dev/null +++ b/src/Report/Html/ClassView/Node/NamespaceNode.php @@ -0,0 +1,397 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node; + +use function array_merge; +use function str_replace; +use Generator; +use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Util\Percentage; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class NamespaceNode +{ + private readonly string $name; + private readonly string $namespace; + private ?self $parent; + + /** + * @var list + */ + private array $childNamespaces = []; + + /** + * @var list + */ + private array $classes = []; + private int $numExecutableLines = -1; + private int $numExecutedLines = -1; + private int $numExecutableBranches = -1; + private int $numExecutedBranches = -1; + private int $numExecutablePaths = -1; + private int $numExecutedPaths = -1; + private int $numClasses = -1; + private int $numTestedClasses = -1; + private int $numMethods = -1; + private int $numTestedMethods = -1; + + public function __construct(string $name, string $namespace, ?self $parent = null) + { + $this->name = $name; + $this->namespace = $namespace; + $this->parent = $parent; + } + + public function name(): string + { + return $this->name; + } + + public function namespace(): string + { + return $this->namespace; + } + + public function parent(): ?self + { + return $this->parent; + } + + public function promoteToRoot(): void + { + $this->parent = null; + } + + public function id(): string + { + if ($this->parent === null) { + return 'index'; + } + + $parentId = $this->parent->id(); + + if ($parentId === 'index') { + return str_replace('\\', '_', $this->name); + } + + return $parentId . '/' . str_replace('\\', '_', $this->name); + } + + /** + * @return non-empty-list + */ + public function pathAsArray(): array + { + if ($this->parent === null) { + return [$this]; + } + + $path = $this->parent->pathAsArray(); + $path[] = $this; + + return $path; + } + + public function addNamespace(self $namespace): void + { + $this->childNamespaces[] = $namespace; + $this->resetCounters(); + } + + public function addClass(ClassNode $class): void + { + $this->classes[] = $class; + $this->resetCounters(); + } + + /** + * @return list + */ + public function childNamespaces(): array + { + return $this->childNamespaces; + } + + /** + * @return list + */ + public function classes(): array + { + return $this->classes; + } + + public function numberOfExecutableLines(): int + { + if ($this->numExecutableLines === -1) { + $this->numExecutableLines = 0; + + foreach ($this->classes as $class) { + $this->numExecutableLines += $class->numberOfExecutableLines(); + } + + foreach ($this->childNamespaces as $ns) { + $this->numExecutableLines += $ns->numberOfExecutableLines(); + } + } + + return $this->numExecutableLines; + } + + public function numberOfExecutedLines(): int + { + if ($this->numExecutedLines === -1) { + $this->numExecutedLines = 0; + + foreach ($this->classes as $class) { + $this->numExecutedLines += $class->numberOfExecutedLines(); + } + + foreach ($this->childNamespaces as $ns) { + $this->numExecutedLines += $ns->numberOfExecutedLines(); + } + } + + return $this->numExecutedLines; + } + + public function numberOfExecutableBranches(): int + { + if ($this->numExecutableBranches === -1) { + $this->numExecutableBranches = 0; + + foreach ($this->classes as $class) { + $this->numExecutableBranches += $class->numberOfExecutableBranches(); + } + + foreach ($this->childNamespaces as $ns) { + $this->numExecutableBranches += $ns->numberOfExecutableBranches(); + } + } + + return $this->numExecutableBranches; + } + + public function numberOfExecutedBranches(): int + { + if ($this->numExecutedBranches === -1) { + $this->numExecutedBranches = 0; + + foreach ($this->classes as $class) { + $this->numExecutedBranches += $class->numberOfExecutedBranches(); + } + + foreach ($this->childNamespaces as $ns) { + $this->numExecutedBranches += $ns->numberOfExecutedBranches(); + } + } + + return $this->numExecutedBranches; + } + + public function numberOfExecutablePaths(): int + { + if ($this->numExecutablePaths === -1) { + $this->numExecutablePaths = 0; + + foreach ($this->classes as $class) { + $this->numExecutablePaths += $class->numberOfExecutablePaths(); + } + + foreach ($this->childNamespaces as $ns) { + $this->numExecutablePaths += $ns->numberOfExecutablePaths(); + } + } + + return $this->numExecutablePaths; + } + + public function numberOfExecutedPaths(): int + { + if ($this->numExecutedPaths === -1) { + $this->numExecutedPaths = 0; + + foreach ($this->classes as $class) { + $this->numExecutedPaths += $class->numberOfExecutedPaths(); + } + + foreach ($this->childNamespaces as $ns) { + $this->numExecutedPaths += $ns->numberOfExecutedPaths(); + } + } + + return $this->numExecutedPaths; + } + + public function numberOfClasses(): int + { + if ($this->numClasses === -1) { + $this->numClasses = 0; + + foreach ($this->classes as $class) { + if ($class->numberOfMethods() > 0) { + $this->numClasses++; + } + } + + foreach ($this->childNamespaces as $ns) { + $this->numClasses += $ns->numberOfClasses(); + } + } + + return $this->numClasses; + } + + public function numberOfTestedClasses(): int + { + if ($this->numTestedClasses === -1) { + $this->numTestedClasses = 0; + + foreach ($this->classes as $class) { + if ($class->numberOfMethods() > 0 && $class->numberOfTestedMethods() === $class->numberOfMethods()) { + $this->numTestedClasses++; + } + } + + foreach ($this->childNamespaces as $ns) { + $this->numTestedClasses += $ns->numberOfTestedClasses(); + } + } + + return $this->numTestedClasses; + } + + public function numberOfMethods(): int + { + if ($this->numMethods === -1) { + $this->numMethods = 0; + + foreach ($this->classes as $class) { + $this->numMethods += $class->numberOfMethods(); + } + + foreach ($this->childNamespaces as $ns) { + $this->numMethods += $ns->numberOfMethods(); + } + } + + return $this->numMethods; + } + + public function numberOfTestedMethods(): int + { + if ($this->numTestedMethods === -1) { + $this->numTestedMethods = 0; + + foreach ($this->classes as $class) { + $this->numTestedMethods += $class->numberOfTestedMethods(); + } + + foreach ($this->childNamespaces as $ns) { + $this->numTestedMethods += $ns->numberOfTestedMethods(); + } + } + + return $this->numTestedMethods; + } + + public function percentageOfExecutedLines(): Percentage + { + return Percentage::fromFractionAndTotal( + $this->numberOfExecutedLines(), + $this->numberOfExecutableLines(), + ); + } + + public function percentageOfExecutedBranches(): Percentage + { + return Percentage::fromFractionAndTotal( + $this->numberOfExecutedBranches(), + $this->numberOfExecutableBranches(), + ); + } + + public function percentageOfExecutedPaths(): Percentage + { + return Percentage::fromFractionAndTotal( + $this->numberOfExecutedPaths(), + $this->numberOfExecutablePaths(), + ); + } + + public function percentageOfTestedMethods(): Percentage + { + return Percentage::fromFractionAndTotal( + $this->numberOfTestedMethods(), + $this->numberOfMethods(), + ); + } + + public function percentageOfTestedClasses(): Percentage + { + return Percentage::fromFractionAndTotal( + $this->numberOfTestedClasses(), + $this->numberOfClasses(), + ); + } + + /** + * @return array + */ + public function allClassTypes(): array + { + $result = []; + + foreach ($this->classes as $class) { + $result[$class->className()] = $class->class_(); + } + + foreach ($this->childNamespaces as $ns) { + $result = array_merge($result, $ns->allClassTypes()); + } + + return $result; + } + + /** + * Yields all ClassNode and NamespaceNode descendants (depth-first). + * + * @return Generator + */ + public function iterate(): Generator + { + foreach ($this->childNamespaces as $ns) { + yield $ns; + + yield from $ns->iterate(); + } + + foreach ($this->classes as $class) { + yield $class; + } + } + + private function resetCounters(): void + { + $this->numExecutableLines = -1; + $this->numExecutedLines = -1; + $this->numExecutableBranches = -1; + $this->numExecutedBranches = -1; + $this->numExecutablePaths = -1; + $this->numExecutedPaths = -1; + $this->numClasses = -1; + $this->numTestedClasses = -1; + $this->numMethods = -1; + $this->numTestedMethods = -1; + } +} diff --git a/src/Report/Html/ClassView/Node/ParentSection.php b/src/Report/Html/ClassView/Node/ParentSection.php new file mode 100644 index 000000000..03f49470e --- /dev/null +++ b/src/Report/Html/ClassView/Node/ParentSection.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node; + +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\Node\File as FileNode; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final readonly class ParentSection +{ + /** + * @var non-empty-string + */ + public string $className; + + /** + * @var non-empty-string + */ + public string $filePath; + + /** + * @var array + */ + public array $methods; + public FileNode $fileNode; + + /** + * @param non-empty-string $className + * @param non-empty-string $filePath + * @param array $methods + */ + public function __construct(string $className, string $filePath, array $methods, FileNode $fileNode) + { + $this->className = $className; + $this->filePath = $filePath; + $this->methods = $methods; + $this->fileNode = $fileNode; + } +} diff --git a/src/Report/Html/ClassView/Node/TraitSection.php b/src/Report/Html/ClassView/Node/TraitSection.php new file mode 100644 index 000000000..e05453a56 --- /dev/null +++ b/src/Report/Html/ClassView/Node/TraitSection.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node; + +use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; +use SebastianBergmann\CodeCoverage\Node\File as FileNode; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final readonly class TraitSection +{ + /** + * @var non-empty-string + */ + public string $traitName; + + /** + * @var non-empty-string + */ + public string $filePath; + public int $startLine; + public int $endLine; + public ProcessedTraitType $trait; + public FileNode $fileNode; + + /** + * @param non-empty-string $traitName + * @param non-empty-string $filePath + */ + public function __construct(string $traitName, string $filePath, int $startLine, int $endLine, ProcessedTraitType $trait, FileNode $fileNode) + { + $this->traitName = $traitName; + $this->filePath = $filePath; + $this->startLine = $startLine; + $this->endLine = $endLine; + $this->trait = $trait; + $this->fileNode = $fileNode; + } +} diff --git a/src/Report/Html/ClassView/Renderer/Class_.php b/src/Report/Html/ClassView/Renderer/Class_.php new file mode 100644 index 000000000..4f854ca42 --- /dev/null +++ b/src/Report/Html/ClassView/Renderer/Class_.php @@ -0,0 +1,386 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Renderer; + +use function array_key_exists; +use function array_pop; +use function count; +use function htmlspecialchars; +use function sprintf; +use function str_repeat; +use function substr_count; +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\ClassNode; +use SebastianBergmann\CodeCoverage\Report\Html\Renderer; +use SebastianBergmann\CodeCoverage\Util\Percentage; +use SebastianBergmann\Template\Exception; +use SebastianBergmann\Template\Template; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class Class_ extends Renderer +{ + public function render(ClassNode $node, string $file): void + { + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'class_branch.html' : 'class.html'); + $template = new Template($templateName, '{{', '}}'); + + $this->setCommonTemplateVariablesForClass($template, $node); + + $sections = $this->renderSourceSections($node); + + $template->setVar( + [ + 'items' => $this->renderItems($node), + 'sections' => $sections, + 'legend' => '

Covered by small (and larger) testsCovered by medium (and large) testsCovered by large tests (and tests of unknown size)Not coveredNot coverable

', + ], + ); + + try { + $template->renderTo($file); + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + } + } + + protected function setCommonTemplateVariablesForClass(Template $template, ClassNode $node): void + { + $nsNode = $node->parent(); + $pathToRoot = $this->pathToRootForClass($node); + + $template->setVar( + [ + 'id' => $nsNode->id() . '/' . $node->shortName(), + 'full_path' => $node->className(), + 'path_to_root' => $pathToRoot, + 'breadcrumbs' => $this->breadcrumbsForClass($node), + 'date' => $this->date, + 'version' => $this->version, + 'runtime' => $this->runtimeString(), + 'generator' => $this->generator, + 'low_upper_bound' => (string) $this->thresholds->lowUpperBound(), + 'high_lower_bound' => (string) $this->thresholds->highLowerBound(), + 'view_switcher' => $this->viewSwitcher($pathToRoot, 'classes', $node->fileNode()->id() . '.html'), + ], + ); + } + + protected function breadcrumbsForClass(ClassNode $node): string + { + $breadcrumbs = ''; + $nsPath = $node->parent()->pathAsArray(); + $pathToRoot = []; + $max = count($nsPath); + + for ($i = 0; $i < $max; $i++) { + $pathToRoot[] = str_repeat('../', $i); + } + + foreach ($nsPath as $step) { + $breadcrumbs .= sprintf( + ' ' . "\n", + array_pop($pathToRoot), + $step->name(), + ); + } + + $breadcrumbs .= sprintf( + ' ' . "\n", + $node->shortName(), + ); + + return $breadcrumbs; + } + + private function renderItems(ClassNode $node): string + { + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'class_item_branch.html' : 'class_item.html'); + $template = new Template($templateName, '{{', '}}'); + + $methodTemplateName = $this->templatePath . ($this->hasBranchCoverage ? 'method_item_branch.html' : 'method_item.html'); + $methodItemTemplate = new Template($methodTemplateName, '{{', '}}'); + + $items = $this->renderItemTemplate( + $template, + [ + 'name' => 'Total', + 'numClasses' => $node->numberOfMethods() > 0 ? 1 : 0, + 'numTestedClasses' => ($node->numberOfMethods() > 0 && $node->numberOfTestedMethods() === $node->numberOfMethods()) ? 1 : 0, + 'numMethods' => $node->numberOfMethods(), + 'numTestedMethods' => $node->numberOfTestedMethods(), + 'linesExecutedPercent' => $node->percentageOfExecutedLines()->asFloat(), + 'linesExecutedPercentAsString' => $node->percentageOfExecutedLines()->asString(), + 'numExecutedLines' => $node->numberOfExecutedLines(), + 'numExecutableLines' => $node->numberOfExecutableLines(), + 'branchesExecutedPercent' => $node->percentageOfExecutedBranches()->asFloat(), + 'branchesExecutedPercentAsString' => $node->percentageOfExecutedBranches()->asString(), + 'numExecutedBranches' => $node->numberOfExecutedBranches(), + 'numExecutableBranches' => $node->numberOfExecutableBranches(), + 'pathsExecutedPercent' => $node->percentageOfExecutedPaths()->asFloat(), + 'pathsExecutedPercentAsString' => $node->percentageOfExecutedPaths()->asString(), + 'numExecutedPaths' => $node->numberOfExecutedPaths(), + 'numExecutablePaths' => $node->numberOfExecutablePaths(), + 'testedMethodsPercent' => $node->percentageOfTestedMethods()->asFloat(), + 'testedMethodsPercentAsString' => $node->percentageOfTestedMethods()->asString(), + 'testedClassesPercent' => $node->percentageOfTestedClasses()->asFloat(), + 'testedClassesPercentAsString' => $node->percentageOfTestedClasses()->asString(), + 'crap' => 'CRAP', + ], + ); + + // Own methods + foreach ($node->class_()->methods as $method) { + $items .= $this->renderMethodItem($methodItemTemplate, $method); + } + + // Trait methods + foreach ($node->traitSections() as $section) { + foreach ($section->trait->methods as $methodName => $method) { + $items .= $this->renderMethodItem( + $methodItemTemplate, + $method, + ' [' . htmlspecialchars($section->traitName, self::HTML_SPECIAL_CHARS_FLAGS) . '] ', + ); + } + } + + // Inherited methods + foreach ($node->parentSections() as $section) { + foreach ($section->methods as $methodName => $method) { + $items .= $this->renderMethodItem( + $methodItemTemplate, + $method, + ' [' . htmlspecialchars($section->className, self::HTML_SPECIAL_CHARS_FLAGS) . '] ', + ); + } + } + + return $items; + } + + private function renderMethodItem(Template $template, ProcessedMethodType $method, string $indent = ' '): string + { + $numMethods = 0; + $numTestedMethods = 0; + + if ($method->executableLines > 0) { + $numMethods = 1; + + if ($method->executedLines === $method->executableLines) { + $numTestedMethods = 1; + } + } + + $executedLinesPercentage = Percentage::fromFractionAndTotal( + $method->executedLines, + $method->executableLines, + ); + + $executedBranchesPercentage = Percentage::fromFractionAndTotal( + $method->executedBranches, + $method->executableBranches, + ); + + $executedPathsPercentage = Percentage::fromFractionAndTotal( + $method->executedPaths, + $method->executablePaths, + ); + + $testedMethodsPercentage = Percentage::fromFractionAndTotal( + $numTestedMethods, + 1, + ); + + return $this->renderItemTemplate( + $template, + [ + 'name' => sprintf( + '%s%s', + $indent, + $method->startLine, + htmlspecialchars($method->signature, self::HTML_SPECIAL_CHARS_FLAGS), + $method->methodName, + ), + 'numMethods' => $numMethods, + 'numTestedMethods' => $numTestedMethods, + 'linesExecutedPercent' => $executedLinesPercentage->asFloat(), + 'linesExecutedPercentAsString' => $executedLinesPercentage->asString(), + 'numExecutedLines' => $method->executedLines, + 'numExecutableLines' => $method->executableLines, + 'branchesExecutedPercent' => $executedBranchesPercentage->asFloat(), + 'branchesExecutedPercentAsString' => $executedBranchesPercentage->asString(), + 'numExecutedBranches' => $method->executedBranches, + 'numExecutableBranches' => $method->executableBranches, + 'pathsExecutedPercent' => $executedPathsPercentage->asFloat(), + 'pathsExecutedPercentAsString' => $executedPathsPercentage->asString(), + 'numExecutedPaths' => $method->executedPaths, + 'numExecutablePaths' => $method->executablePaths, + 'testedMethodsPercent' => $testedMethodsPercentage->asFloat(), + 'testedMethodsPercentAsString' => $testedMethodsPercentage->asString(), + 'crap' => $method->crap, + ], + ); + } + + private function renderSourceSections(ClassNode $node): string + { + $sections = ''; + + // Own source + $sections .= $this->renderSourceSection( + $node->shortName(), + $node->filePath(), + $node->startLine(), + $node->endLine(), + $node->fileNode()->lineCoverageData(), + $node->fileNode()->testData(), + ); + + // Trait source sections + foreach ($node->traitSections() as $section) { + $sections .= $this->renderSectionHeader('From ' . $section->traitName); + + $sections .= $this->renderSourceSection( + $section->traitName, + $section->filePath, + $section->startLine, + $section->endLine, + $section->fileNode->lineCoverageData(), + $section->fileNode->testData(), + ); + } + + // Parent source sections + foreach ($node->parentSections() as $section) { + $sections .= $this->renderSectionHeader('Inherited from ' . $section->className); + + foreach ($section->methods as $method) { + $sections .= $this->renderSourceSection( + $section->className . '::' . $method->methodName, + $section->filePath, + $method->startLine, + $method->endLine, + $section->fileNode->lineCoverageData(), + $section->fileNode->testData(), + ); + } + } + + return $sections; + } + + private function renderSectionHeader(string $title): string + { + $template = new Template($this->templatePath . 'section_header.html.dist', '{{', '}}'); + $template->setVar(['title' => htmlspecialchars($title, self::HTML_SPECIAL_CHARS_FLAGS)]); + + return $template->render(); + } + + /** + * @param array> $coverageData + * @param array $testData + */ + private function renderSourceSection(string $label, string $filePath, int $startLine, int $endLine, array $coverageData, array $testData): string + { + $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); + $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}'); + + $codeLines = $this->loadFile($filePath); + $lines = ''; + + for ($i = $startLine; $i <= $endLine; $i++) { + $lineIndex = $i - 1; + + if (!isset($codeLines[$lineIndex])) { + continue; + } + + $trClass = ''; + $popoverContent = ''; + $popoverTitle = ''; + + if (array_key_exists($i, $coverageData)) { + $numTests = ($coverageData[$i] !== null ? count($coverageData[$i]) : 0); + + if ($coverageData[$i] === null) { + $trClass = 'warning'; + } elseif ($numTests === 0) { + $trClass = 'danger'; + } else { + if ($numTests > 1) { + $popoverTitle = $numTests . ' tests cover line ' . $i; + } else { + $popoverTitle = '1 test covers line ' . $i; + } + + $lineCss = 'covered-by-large-tests'; + $popoverContent = '
    '; + + foreach ($coverageData[$i] as $test) { + if ($lineCss === 'covered-by-large-tests' && isset($testData[$test]) && $testData[$test]['size'] === 'medium') { + $lineCss = 'covered-by-medium-tests'; + } elseif (isset($testData[$test]) && $testData[$test]['size'] === 'small') { + $lineCss = 'covered-by-small-tests'; + } + + if (isset($testData[$test])) { + $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); + } + } + + $popoverContent .= '
'; + $trClass = $lineCss . ' popin'; + } + } + + $popover = ''; + + if ($popoverTitle !== '') { + $popover = sprintf( + ' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"', + $popoverTitle, + htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS), + ); + } + + $lines .= $this->renderLine($singleLineTemplate, $i, $codeLines[$lineIndex], $trClass, $popover); + } + + $linesTemplate->setVar(['lines' => $lines]); + + return $linesTemplate->render(); + } + + private function pathToRootForClass(ClassNode $node): string + { + $nsNode = $node->parent(); + $id = $nsNode->id(); + $depth = substr_count($id, '/'); + + if ($id !== 'index') { + $depth++; + } + + // One extra level for the _classes/ directory + $depth++; + + return str_repeat('../', $depth); + } +} diff --git a/src/Report/Html/ClassView/Renderer/Dashboard.php b/src/Report/Html/ClassView/Renderer/Dashboard.php new file mode 100644 index 000000000..ca453be5b --- /dev/null +++ b/src/Report/Html/ClassView/Renderer/Dashboard.php @@ -0,0 +1,353 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Renderer; + +use function array_pop; +use function array_values; +use function asort; +use function assert; +use function count; +use function explode; +use function floor; +use function json_encode; +use function sprintf; +use function str_repeat; +use function str_replace; +use function substr_count; +use function uasort; +use function usort; +use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\NamespaceNode; +use SebastianBergmann\CodeCoverage\Report\Html\Renderer; +use SebastianBergmann\Template\Exception; +use SebastianBergmann\Template\Template; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class Dashboard extends Renderer +{ + public function render(NamespaceNode $node, string $file): void + { + $classes = $node->allClassTypes(); + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'dashboard_branch.html' : 'dashboard.html'); + $template = new Template($templateName, '{{', '}}'); + + $this->setCommonTemplateVariablesForNamespace($template, $node); + + $baseLink = $node->id() . '/'; + $complexity = $this->complexity($classes, $baseLink); + $coverageDistribution = $this->coverageDistribution($classes); + $insufficientCoverage = $this->insufficientCoverage($classes, $baseLink); + $projectRisks = $this->projectRisks($classes, $baseLink); + + $template->setVar( + [ + 'insufficient_coverage_classes' => $insufficientCoverage['class'], + 'insufficient_coverage_methods' => $insufficientCoverage['method'], + 'project_risks_classes' => $projectRisks['class'], + 'project_risks_methods' => $projectRisks['method'], + 'complexity_class' => $complexity['class'], + 'complexity_method' => $complexity['method'], + 'class_coverage_distribution' => $coverageDistribution['class'], + 'method_coverage_distribution' => $coverageDistribution['method'], + ], + ); + + try { + $template->renderTo($file); + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + } + } + + protected function setCommonTemplateVariablesForNamespace(Template $template, NamespaceNode $node): void + { + $pathToRoot = $this->pathToRootForNamespace($node); + + $template->setVar( + [ + 'id' => $node->id(), + 'full_path' => $node->namespace() !== '' ? $node->namespace() : '(Global)', + 'path_to_root' => $pathToRoot, + 'breadcrumbs' => $this->breadcrumbsForDashboard($node), + 'date' => $this->date, + 'version' => $this->version, + 'runtime' => $this->runtimeString(), + 'generator' => $this->generator, + 'low_upper_bound' => (string) $this->thresholds->lowUpperBound(), + 'high_lower_bound' => (string) $this->thresholds->highLowerBound(), + 'view_switcher' => $this->viewSwitcher($pathToRoot, 'classes'), + ], + ); + } + + protected function breadcrumbsForDashboard(NamespaceNode $node): string + { + $breadcrumbs = ''; + $path = $node->pathAsArray(); + $pathToRoot = []; + $max = count($path); + + for ($i = 0; $i < $max; $i++) { + $pathToRoot[] = str_repeat('../', $i); + } + + foreach ($path as $step) { + if ($step !== $node) { + $breadcrumbs .= sprintf( + ' ' . "\n", + array_pop($pathToRoot), + $step->name(), + ); + } else { + $breadcrumbs .= sprintf( + ' ' . "\n", + $step->name(), + ); + $breadcrumbs .= ' ' . "\n"; + } + } + + return $breadcrumbs; + } + + private function pathToRootForNamespace(NamespaceNode $node): string + { + $id = $node->id(); + $depth = substr_count($id, '/'); + + if ($id !== 'index') { + $depth++; + } + + // One extra level for the _classes/ directory + $depth++; + + return str_repeat('../', $depth); + } + + /** + * @param array $classes + * + * @return array{class: non-empty-string, method: non-empty-string} + */ + private function complexity(array $classes, string $baseLink): array + { + $result = ['class' => [], 'method' => []]; + + foreach ($classes as $className => $class) { + foreach ($class->methods as $methodName => $method) { + $result['method'][] = [ + $method->coverage, + $method->ccn, + str_replace($baseLink, '', $method->link), + $className . '::' . $methodName, + $method->crap, + ]; + } + + $result['class'][] = [ + $class->coverage, + $class->ccn, + str_replace($baseLink, '', $class->link), + $className, + $class->crap, + ]; + } + + usort($result['class'], static fn (mixed $a, mixed $b) => ($a[0] <=> $b[0])); + usort($result['method'], static fn (mixed $a, mixed $b) => ($a[0] <=> $b[0])); + + $class = json_encode($result['class']); + + assert($class !== false); + + $method = json_encode($result['method']); + + assert($method !== false); + + return ['class' => $class, 'method' => $method]; + } + + /** + * @param array $classes + * + * @return array{class: non-empty-string, method: non-empty-string} + */ + private function coverageDistribution(array $classes): array + { + $result = [ + 'class' => [ + '0%' => 0, '0-10%' => 0, '10-20%' => 0, '20-30%' => 0, + '30-40%' => 0, '40-50%' => 0, '50-60%' => 0, '60-70%' => 0, + '70-80%' => 0, '80-90%' => 0, '90-100%' => 0, '100%' => 0, + ], + 'method' => [ + '0%' => 0, '0-10%' => 0, '10-20%' => 0, '20-30%' => 0, + '30-40%' => 0, '40-50%' => 0, '50-60%' => 0, '60-70%' => 0, + '70-80%' => 0, '80-90%' => 0, '90-100%' => 0, '100%' => 0, + ], + ]; + + foreach ($classes as $class) { + foreach ($class->methods as $method) { + if ($method->coverage === 0) { + $result['method']['0%']++; + } elseif ($method->coverage === 100) { + $result['method']['100%']++; + } else { + $key = floor($method->coverage / 10) * 10; + $key = $key . '-' . ($key + 10) . '%'; + $result['method'][$key]++; + } + } + + if ($class->coverage === 0) { + $result['class']['0%']++; + } elseif ($class->coverage === 100) { + $result['class']['100%']++; + } else { + $key = floor($class->coverage / 10) * 10; + $key = $key . '-' . ($key + 10) . '%'; + $result['class'][$key]++; + } + } + + $class = json_encode(array_values($result['class'])); + + assert($class !== false); + + $method = json_encode(array_values($result['method'])); + + assert($method !== false); + + return ['class' => $class, 'method' => $method]; + } + + /** + * @param array $classes + * + * @return array{class: string, method: string} + */ + private function insufficientCoverage(array $classes, string $baseLink): array + { + $leastTestedClasses = []; + $leastTestedMethods = []; + $result = ['class' => '', 'method' => '']; + + foreach ($classes as $className => $class) { + foreach ($class->methods as $methodName => $method) { + if ($method->coverage < $this->thresholds->highLowerBound()) { + $leastTestedMethods[$className . '::' . $methodName] = $method->coverage; + } + } + + if ($class->coverage < $this->thresholds->highLowerBound()) { + $leastTestedClasses[$className] = $class->coverage; + } + } + + asort($leastTestedClasses); + asort($leastTestedMethods); + + foreach ($leastTestedClasses as $className => $coverage) { + $result['class'] .= sprintf( + ' %s%d%%' . "\n", + str_replace($baseLink, '', $classes[$className]->link), + $className, + $coverage, + ); + } + + foreach ($leastTestedMethods as $methodName => $coverage) { + [$class, $method] = explode('::', $methodName); + + $result['method'] .= sprintf( + ' %s%d%%' . "\n", + str_replace($baseLink, '', $classes[$class]->methods[$method]->link), + $methodName, + $method, + $coverage, + ); + } + + return $result; + } + + /** + * @param array $classes + * + * @return array{class: string, method: string} + */ + private function projectRisks(array $classes, string $baseLink): array + { + $classRisks = []; + $methodRisks = []; + $result = ['class' => '', 'method' => '']; + + foreach ($classes as $className => $class) { + foreach ($class->methods as $methodName => $method) { + if ($method->coverage < $this->thresholds->highLowerBound() && $method->ccn > 1) { + $methodRisks[$className . '::' . $methodName] = $method; + } + } + + if ($class->coverage < $this->thresholds->highLowerBound() && + $class->ccn > count($class->methods)) { + $classRisks[$className] = $class; + } + } + + uasort($classRisks, static function (ProcessedClassType $a, ProcessedClassType $b) + { + return ((int) ($a->crap) <=> (int) ($b->crap)) * -1; + }); + uasort($methodRisks, static function (ProcessedMethodType $a, ProcessedMethodType $b) + { + return ((int) ($a->crap) <=> (int) ($b->crap)) * -1; + }); + + foreach ($classRisks as $className => $class) { + $result['class'] .= sprintf( + ' %s%.1f%%%d%d' . "\n", + str_replace($baseLink, '', $classes[$className]->link), + $className, + $class->coverage, + $class->ccn, + $class->crap, + ); + } + + foreach ($methodRisks as $methodName => $methodVals) { + [$class, $method] = explode('::', $methodName); + + $result['method'] .= sprintf( + ' %s%.1f%%%d%d' . "\n", + str_replace($baseLink, '', $classes[$class]->methods[$method]->link), + $methodName, + $method, + $methodVals->coverage, + $methodVals->ccn, + $methodVals->crap, + ); + } + + return $result; + } +} diff --git a/src/Report/Html/ClassView/Renderer/Namespace_.php b/src/Report/Html/ClassView/Renderer/Namespace_.php new file mode 100644 index 000000000..3fc31c04c --- /dev/null +++ b/src/Report/Html/ClassView/Renderer/Namespace_.php @@ -0,0 +1,222 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Renderer; + +use function array_pop; +use function count; +use function sprintf; +use function str_repeat; +use function substr_count; +use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\ClassNode; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\NamespaceNode; +use SebastianBergmann\CodeCoverage\Report\Html\Renderer; +use SebastianBergmann\Template\Exception; +use SebastianBergmann\Template\Template; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class Namespace_ extends Renderer +{ + public function render(NamespaceNode $node, string $file): void + { + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'namespace_branch.html' : 'namespace.html'); + $template = new Template($templateName, '{{', '}}'); + + $this->setCommonTemplateVariablesForNamespace($template, $node); + + $items = $this->renderItem($node); + + foreach ($node->childNamespaces() as $ns) { + $items .= $this->renderItem($node, $ns); + } + + foreach ($node->classes() as $class) { + $items .= $this->renderClassItem($class); + } + + $template->setVar( + [ + 'id' => $node->id(), + 'items' => $items, + ], + ); + + try { + $template->renderTo($file); + } catch (Exception $e) { + throw new FileCouldNotBeWrittenException( + $e->getMessage(), + $e->getCode(), + $e, + ); + } + } + + protected function setCommonTemplateVariablesForNamespace(Template $template, NamespaceNode $node): void + { + $pathToRoot = $this->pathToRootForNamespace($node); + + $template->setVar( + [ + 'id' => $node->id(), + 'full_path' => $node->namespace() !== '' ? $node->namespace() : '(Global)', + 'path_to_root' => $pathToRoot, + 'breadcrumbs' => $this->breadcrumbsForNamespace($node), + 'date' => $this->date, + 'version' => $this->version, + 'runtime' => $this->runtimeString(), + 'generator' => $this->generator, + 'low_upper_bound' => (string) $this->thresholds->lowUpperBound(), + 'high_lower_bound' => (string) $this->thresholds->highLowerBound(), + 'view_switcher' => $this->viewSwitcher($pathToRoot, 'classes'), + ], + ); + } + + protected function breadcrumbsForNamespace(NamespaceNode $node): string + { + $breadcrumbs = ''; + $path = $node->pathAsArray(); + $pathToRoot = []; + $max = count($path); + + for ($i = 0; $i < $max; $i++) { + $pathToRoot[] = str_repeat('../', $i); + } + + foreach ($path as $step) { + if ($step !== $node) { + $breadcrumbs .= sprintf( + ' ' . "\n", + array_pop($pathToRoot), + $step->name(), + ); + } else { + $breadcrumbs .= sprintf( + ' ' . "\n", + $step->name(), + ); + $breadcrumbs .= ' ' . "\n"; + } + } + + return $breadcrumbs; + } + + private function renderItem(NamespaceNode $currentPage, ?NamespaceNode $child = null): string + { + $statsNode = $child ?? $currentPage; + + $data = [ + 'numClasses' => $statsNode->numberOfClasses(), + 'numTestedClasses' => $statsNode->numberOfTestedClasses(), + 'numMethods' => $statsNode->numberOfMethods(), + 'numTestedMethods' => $statsNode->numberOfTestedMethods(), + 'linesExecutedPercent' => $statsNode->percentageOfExecutedLines()->asFloat(), + 'linesExecutedPercentAsString' => $statsNode->percentageOfExecutedLines()->asString(), + 'numExecutedLines' => $statsNode->numberOfExecutedLines(), + 'numExecutableLines' => $statsNode->numberOfExecutableLines(), + 'branchesExecutedPercent' => $statsNode->percentageOfExecutedBranches()->asFloat(), + 'branchesExecutedPercentAsString' => $statsNode->percentageOfExecutedBranches()->asString(), + 'numExecutedBranches' => $statsNode->numberOfExecutedBranches(), + 'numExecutableBranches' => $statsNode->numberOfExecutableBranches(), + 'pathsExecutedPercent' => $statsNode->percentageOfExecutedPaths()->asFloat(), + 'pathsExecutedPercentAsString' => $statsNode->percentageOfExecutedPaths()->asString(), + 'numExecutedPaths' => $statsNode->numberOfExecutedPaths(), + 'numExecutablePaths' => $statsNode->numberOfExecutablePaths(), + 'testedMethodsPercent' => $statsNode->percentageOfTestedMethods()->asFloat(), + 'testedMethodsPercentAsString' => $statsNode->percentageOfTestedMethods()->asString(), + 'testedClassesPercent' => $statsNode->percentageOfTestedClasses()->asFloat(), + 'testedClassesPercentAsString' => $statsNode->percentageOfTestedClasses()->asString(), + ]; + + if ($child === null) { + $data['name'] = 'Total'; + } else { + $data['icon'] = sprintf( + '', + $this->pathToRootForNamespace($currentPage), + ); + $data['name'] = sprintf( + '%s', + $child->name(), + $child->name(), + ); + } + + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'namespace_item_branch.html' : 'namespace_item.html'); + + return $this->renderItemTemplate( + new Template($templateName, '{{', '}}'), + $data, + ); + } + + private function renderClassItem(ClassNode $class): string + { + $data = [ + 'numClasses' => $class->numberOfMethods() > 0 ? 1 : 0, + 'numTestedClasses' => ($class->numberOfMethods() > 0 && $class->numberOfTestedMethods() === $class->numberOfMethods()) ? 1 : 0, + 'numMethods' => $class->numberOfMethods(), + 'numTestedMethods' => $class->numberOfTestedMethods(), + 'linesExecutedPercent' => $class->percentageOfExecutedLines()->asFloat(), + 'linesExecutedPercentAsString' => $class->percentageOfExecutedLines()->asString(), + 'numExecutedLines' => $class->numberOfExecutedLines(), + 'numExecutableLines' => $class->numberOfExecutableLines(), + 'branchesExecutedPercent' => $class->percentageOfExecutedBranches()->asFloat(), + 'branchesExecutedPercentAsString' => $class->percentageOfExecutedBranches()->asString(), + 'numExecutedBranches' => $class->numberOfExecutedBranches(), + 'numExecutableBranches' => $class->numberOfExecutableBranches(), + 'pathsExecutedPercent' => $class->percentageOfExecutedPaths()->asFloat(), + 'pathsExecutedPercentAsString' => $class->percentageOfExecutedPaths()->asString(), + 'numExecutedPaths' => $class->numberOfExecutedPaths(), + 'numExecutablePaths' => $class->numberOfExecutablePaths(), + 'testedMethodsPercent' => $class->percentageOfTestedMethods()->asFloat(), + 'testedMethodsPercentAsString' => $class->percentageOfTestedMethods()->asString(), + 'testedClassesPercent' => $class->percentageOfTestedClasses()->asFloat(), + 'testedClassesPercentAsString' => $class->percentageOfTestedClasses()->asString(), + 'icon' => sprintf( + '', + $this->pathToRootForNamespace($class->parent()), + ), + 'name' => sprintf( + '%s', + $class->shortName(), + $class->shortName(), + ), + ]; + + $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'namespace_item_branch.html' : 'namespace_item.html'); + + return $this->renderItemTemplate( + new Template($templateName, '{{', '}}'), + $data, + ); + } + + private function pathToRootForNamespace(NamespaceNode $node): string + { + $id = $node->id(); + $depth = substr_count($id, '/'); + + if ($id !== 'index') { + $depth++; + } + + // One extra level for the _classes/ directory + $depth++; + + return str_repeat('../', $depth); + } +} diff --git a/src/Report/Html/Facade.php b/src/Report/Html/Facade.php index be29de10c..88fa0075e 100644 --- a/src/Report/Html/Facade.php +++ b/src/Report/Html/Facade.php @@ -10,12 +10,19 @@ namespace SebastianBergmann\CodeCoverage\Report\Html; use const DIRECTORY_SEPARATOR; +use function array_key_exists; use function copy; use function date; use function dirname; use function str_ends_with; use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Builder; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\ClassNode; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\NamespaceNode; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Renderer\Class_ as ClassRenderer; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Renderer\Dashboard as ClassDashboard; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Renderer\Namespace_ as NamespaceRenderer; use SebastianBergmann\CodeCoverage\Report\Thresholds; use SebastianBergmann\CodeCoverage\Util\Filesystem; use SebastianBergmann\Template\Exception; @@ -49,29 +56,26 @@ public function process(DirectoryNode $report, string $target): void $date = date('D M j G:i:s T Y'); $hasBranchCoverage = $report->numberOfExecutableBranches() > 0; - $dashboard = new Dashboard( - $this->templatePath, - $this->generator, - $date, - $this->thresholds, - $hasBranchCoverage, - ); + $builder = new Builder; + $rootNamespace = $builder->build($report); + $fileToClassMap = $this->buildFileToClassMap($rootNamespace); - $directory = new Directory( - $this->templatePath, - $this->generator, - $date, - $this->thresholds, - $hasBranchCoverage, - ); + $this->renderFileView($report, $target, $date, $hasBranchCoverage, $fileToClassMap); + $this->renderClassView($rootNamespace, $target, $date, $hasBranchCoverage); + $this->copyFiles($target); + $this->renderCss($target); + } - $file = new File( - $this->templatePath, - $this->generator, - $date, - $this->thresholds, - $hasBranchCoverage, - ); + /** + * @param array $fileToClassMap + */ + private function renderFileView(DirectoryNode $report, string $target, string $date, bool $hasBranchCoverage, array $fileToClassMap): void + { + $dashboard = new Dashboard($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage); + $directory = new Directory($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage); + $file = new File($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage); + + $file->setFileToClassMap($fileToClassMap); $directory->render($report, $target . 'index.html'); $dashboard->render($report, $target . 'dashboard.html'); @@ -92,9 +96,72 @@ public function process(DirectoryNode $report, string $target): void $file->render($node, $target . $id); } } + } - $this->copyFiles($target); - $this->renderCss($target); + private function renderClassView(NamespaceNode $rootNamespace, string $target, string $date, bool $hasBranchCoverage): void + { + $classTarget = $this->directory($target . '_classes'); + + $namespaceRenderer = new NamespaceRenderer($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage); + $classRenderer = new ClassRenderer($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage); + $dashboard = new ClassDashboard($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage); + + $namespaceRenderer->render($rootNamespace, $classTarget . 'index.html'); + $dashboard->render($rootNamespace, $classTarget . 'dashboard.html'); + + foreach ($rootNamespace->iterate() as $node) { + if ($node instanceof NamespaceNode) { + $id = $node->id(); + + Filesystem::createDirectory($classTarget . $id); + + $namespaceRenderer->render($node, $classTarget . $id . '/index.html'); + $dashboard->render($node, $classTarget . $id . '/dashboard.html'); + } elseif ($node instanceof ClassNode) { + $nsId = $node->parent()->id(); + + if ($nsId === 'index') { + $dir = $classTarget; + } else { + $dir = $classTarget . $nsId . '/'; + Filesystem::createDirectory($dir); + } + + $classRenderer->render($node, $dir . $node->shortName() . '.html'); + } + } + } + + /** + * @return array + */ + private function buildFileToClassMap(NamespaceNode $rootNamespace): array + { + $map = []; + + foreach ($rootNamespace->iterate() as $node) { + if (!$node instanceof ClassNode) { + continue; + } + + $fileId = $node->fileNode()->id(); + + if (array_key_exists($fileId, $map)) { + continue; + } + + $nsId = $node->parent()->id(); + + if ($nsId === 'index') { + $classPagePath = '_classes/' . $node->shortName() . '.html'; + } else { + $classPagePath = '_classes/' . $nsId . '/' . $node->shortName() . '.html'; + } + + $map[$fileId] = $classPagePath; + } + + return $map; } private function copyFiles(string $target): void diff --git a/src/Report/Html/Renderer.php b/src/Report/Html/Renderer.php index 29b01dc68..2a2640dd0 100644 --- a/src/Report/Html/Renderer.php +++ b/src/Report/Html/Renderer.php @@ -9,11 +9,94 @@ */ namespace SebastianBergmann\CodeCoverage\Report\Html; +use const ENT_COMPAT; +use const ENT_HTML401; +use const ENT_SUBSTITUTE; +use const T_ABSTRACT; +use const T_ARRAY; +use const T_AS; +use const T_BREAK; +use const T_CALLABLE; +use const T_CASE; +use const T_CATCH; +use const T_CLASS; +use const T_CLONE; +use const T_COMMENT; +use const T_CONST; +use const T_CONTINUE; +use const T_DECLARE; +use const T_DEFAULT; +use const T_DO; +use const T_DOC_COMMENT; +use const T_ECHO; +use const T_ELSE; +use const T_ELSEIF; +use const T_EMPTY; +use const T_ENDDECLARE; +use const T_ENDFOR; +use const T_ENDFOREACH; +use const T_ENDIF; +use const T_ENDSWITCH; +use const T_ENDWHILE; +use const T_ENUM; +use const T_EVAL; +use const T_EXIT; +use const T_EXTENDS; +use const T_FINAL; +use const T_FINALLY; +use const T_FN; +use const T_FOR; +use const T_FOREACH; +use const T_FUNCTION; +use const T_GLOBAL; +use const T_GOTO; +use const T_HALT_COMPILER; +use const T_IF; +use const T_IMPLEMENTS; +use const T_INCLUDE; +use const T_INCLUDE_ONCE; +use const T_INLINE_HTML; +use const T_INSTANCEOF; +use const T_INSTEADOF; +use const T_INTERFACE; +use const T_ISSET; +use const T_LIST; +use const T_MATCH; +use const T_NAMESPACE; +use const T_NEW; +use const T_PRINT; +use const T_PRIVATE; +use const T_PROTECTED; +use const T_PUBLIC; +use const T_READONLY; +use const T_REQUIRE; +use const T_REQUIRE_ONCE; +use const T_RETURN; +use const T_STATIC; +use const T_SWITCH; +use const T_THROW; +use const T_TRAIT; +use const T_TRY; +use const T_UNSET; +use const T_USE; +use const T_VAR; +use const T_WHILE; +use const T_YIELD; +use const T_YIELD_FROM; +use const TOKEN_PARSE; use function array_pop; use function count; +use function explode; +use function file_get_contents; +use function htmlspecialchars; +use function is_string; use function sprintf; +use function str_ends_with; use function str_repeat; +use function str_replace; use function substr_count; +use function token_get_all; +use function trim; use SebastianBergmann\CodeCoverage\Node\AbstractNode; use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode; use SebastianBergmann\CodeCoverage\Node\File as FileNode; @@ -29,6 +112,86 @@ */ abstract class Renderer { + /** + * @var array + */ + protected const array KEYWORD_TOKENS = [ + T_ABSTRACT => true, + T_ARRAY => true, + T_AS => true, + T_BREAK => true, + T_CALLABLE => true, + T_CASE => true, + T_CATCH => true, + T_CLASS => true, + T_CLONE => true, + T_CONST => true, + T_CONTINUE => true, + T_DECLARE => true, + T_DEFAULT => true, + T_DO => true, + T_ECHO => true, + T_ELSE => true, + T_ELSEIF => true, + T_EMPTY => true, + T_ENDDECLARE => true, + T_ENDFOR => true, + T_ENDFOREACH => true, + T_ENDIF => true, + T_ENDSWITCH => true, + T_ENDWHILE => true, + T_ENUM => true, + T_EVAL => true, + T_EXIT => true, + T_EXTENDS => true, + T_FINAL => true, + T_FINALLY => true, + T_FN => true, + T_FOR => true, + T_FOREACH => true, + T_FUNCTION => true, + T_GLOBAL => true, + T_GOTO => true, + T_HALT_COMPILER => true, + T_IF => true, + T_IMPLEMENTS => true, + T_INCLUDE => true, + T_INCLUDE_ONCE => true, + T_INSTANCEOF => true, + T_INSTEADOF => true, + T_INTERFACE => true, + T_ISSET => true, + T_LIST => true, + T_MATCH => true, + T_NAMESPACE => true, + T_NEW => true, + T_PRINT => true, + T_PRIVATE => true, + T_PROTECTED => true, + T_PUBLIC => true, + T_READONLY => true, + T_REQUIRE => true, + T_REQUIRE_ONCE => true, + T_RETURN => true, + T_STATIC => true, + T_SWITCH => true, + T_THROW => true, + T_TRAIT => true, + T_TRY => true, + T_UNSET => true, + T_USE => true, + T_VAR => true, + T_WHILE => true, + T_YIELD => true, + T_YIELD_FROM => true, + ]; + + protected const int HTML_SPECIAL_CHARS_FLAGS = ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE; + + /** + * @var array> + */ + protected static array $formattedSourceCache = []; protected string $templatePath; protected string $generator; protected string $date; @@ -36,6 +199,11 @@ abstract class Renderer protected bool $hasBranchCoverage; protected string $version; + /** + * @var array + */ + private array $fileToClassMap = []; + public function __construct(string $templatePath, string $generator, string $date, Thresholds $thresholds, bool $hasBranchCoverage) { $this->templatePath = $templatePath; @@ -46,6 +214,14 @@ public function __construct(string $templatePath, string $generator, string $dat $this->hasBranchCoverage = $hasBranchCoverage; } + /** + * @param array $map + */ + public function setFileToClassMap(array $map): void + { + $this->fileToClassMap = $map; + } + /** * @param array $data */ @@ -166,11 +342,18 @@ protected function renderItemTemplate(Template $template, array $data): string protected function setCommonTemplateVariables(Template $template, AbstractNode $node): void { + $pathToRoot = $this->pathToRoot($node); + $classesTarget = '_classes/index.html'; + + if ($node instanceof FileNode && isset($this->fileToClassMap[$node->id()])) { + $classesTarget = $this->fileToClassMap[$node->id()]; + } + $template->setVar( [ 'id' => $node->id(), 'full_path' => $node->pathAsString(), - 'path_to_root' => $this->pathToRoot($node), + 'path_to_root' => $pathToRoot, 'breadcrumbs' => $this->breadcrumbs($node), 'date' => $this->date, 'version' => $this->version, @@ -178,10 +361,36 @@ protected function setCommonTemplateVariables(Template $template, AbstractNode $ 'generator' => $this->generator, 'low_upper_bound' => (string) $this->thresholds->lowUpperBound(), 'high_lower_bound' => (string) $this->thresholds->highLowerBound(), + 'view_switcher' => $this->viewSwitcher($pathToRoot, 'files', 'index.html', $classesTarget), ], ); } + protected function viewSwitcher(string $pathToRoot, string $activeView, string $filesTarget = 'index.html', string $classesTarget = '_classes/index.html'): string + { + if ($activeView === 'files') { + return sprintf( + ' ' . "\n", + $pathToRoot, + $pathToRoot, + $classesTarget, + ); + } + + return sprintf( + ' ' . "\n", + $pathToRoot, + $filesTarget, + $pathToRoot, + ); + } + protected function breadcrumbs(AbstractNode $node): string { $breadcrumbs = ''; @@ -277,7 +486,142 @@ protected function colorLevel(float $percent): string return 'success'; } - private function runtimeString(): string + protected function renderLine(Template $template, int $lineNumber, string $lineContent, string $class, string $popover): string + { + $template->setVar( + [ + 'lineNumber' => (string) $lineNumber, + 'lineContent' => $lineContent, + 'class' => $class, + 'popover' => $popover, + ], + ); + + return $template->render(); + } + + protected function createPopoverContentForTest(string $test, array $testData): string + { + $testCSS = ''; + + switch ($testData['status']) { + case 'success': + $testCSS = match ($testData['size']) { + 'small' => ' class="covered-by-small-tests"', + 'medium' => ' class="covered-by-medium-tests"', + // no break + default => ' class="covered-by-large-tests"', + }; + + break; + + case 'failure': + $testCSS = ' class="danger"'; + + break; + } + + return sprintf( + '%s', + $testCSS, + htmlspecialchars($test, self::HTML_SPECIAL_CHARS_FLAGS), + ); + } + + /** + * @param non-empty-string $file + * + * @return list + */ + protected function loadFile(string $file): array + { + if (isset(self::$formattedSourceCache[$file])) { + return self::$formattedSourceCache[$file]; + } + + $buffer = file_get_contents($file); + $tokens = token_get_all($buffer, TOKEN_PARSE); + $result = ['']; + $i = 0; + $stringFlag = false; + $fileEndsWithNewLine = str_ends_with($buffer, "\n"); + + unset($buffer); + + foreach ($tokens as $j => $token) { + if (is_string($token)) { + if ($token === '"' && $tokens[$j - 1] !== '\\') { + $result[$i] .= sprintf( + '%s', + htmlspecialchars($token, self::HTML_SPECIAL_CHARS_FLAGS), + ); + + $stringFlag = !$stringFlag; + } else { + $result[$i] .= sprintf( + '%s', + htmlspecialchars($token, self::HTML_SPECIAL_CHARS_FLAGS), + ); + } + + continue; + } + + [$token, $value] = $token; + + $value = str_replace( + ["\t", ' '], + ['    ', ' '], + htmlspecialchars($value, self::HTML_SPECIAL_CHARS_FLAGS), + ); + + if ($value === "\n") { + $result[++$i] = ''; + } else { + $lines = explode("\n", $value); + + foreach ($lines as $jj => $line) { + $line = trim($line); + + if ($line !== '') { + if ($stringFlag) { + $colour = 'string'; + } else { + $colour = 'default'; + + if ($this->isInlineHtml($token)) { + $colour = 'html'; + } elseif ($this->isComment($token)) { + $colour = 'comment'; + } elseif ($this->isKeyword($token)) { + $colour = 'keyword'; + } + } + + $result[$i] .= sprintf( + '%s', + $colour, + $line, + ); + } + + if (isset($lines[$jj + 1])) { + $result[++$i] = ''; + } + } + } + } + + if ($fileEndsWithNewLine) { + unset($result[count($result) - 1]); + } + + self::$formattedSourceCache[$file] = $result; + + return $result; + } + + protected function runtimeString(): string { $runtime = new Runtime; @@ -288,4 +632,19 @@ private function runtimeString(): string $runtime->getVersion(), ); } + + private function isComment(int $token): bool + { + return $token === T_COMMENT || $token === T_DOC_COMMENT; + } + + private function isInlineHtml(int $token): bool + { + return $token === T_INLINE_HTML; + } + + private function isKeyword(int $token): bool + { + return isset(self::KEYWORD_TOKENS[$token]); + } } diff --git a/src/Report/Html/Renderer/File.php b/src/Report/Html/Renderer/File.php index 6dbbc5763..a2f26893f 100644 --- a/src/Report/Html/Renderer/File.php +++ b/src/Report/Html/Renderer/File.php @@ -9,81 +9,6 @@ */ namespace SebastianBergmann\CodeCoverage\Report\Html; -use const ENT_COMPAT; -use const ENT_HTML401; -use const ENT_SUBSTITUTE; -use const T_ABSTRACT; -use const T_ARRAY; -use const T_AS; -use const T_BREAK; -use const T_CALLABLE; -use const T_CASE; -use const T_CATCH; -use const T_CLASS; -use const T_CLONE; -use const T_COMMENT; -use const T_CONST; -use const T_CONTINUE; -use const T_DECLARE; -use const T_DEFAULT; -use const T_DO; -use const T_DOC_COMMENT; -use const T_ECHO; -use const T_ELSE; -use const T_ELSEIF; -use const T_EMPTY; -use const T_ENDDECLARE; -use const T_ENDFOR; -use const T_ENDFOREACH; -use const T_ENDIF; -use const T_ENDSWITCH; -use const T_ENDWHILE; -use const T_ENUM; -use const T_EVAL; -use const T_EXIT; -use const T_EXTENDS; -use const T_FINAL; -use const T_FINALLY; -use const T_FN; -use const T_FOR; -use const T_FOREACH; -use const T_FUNCTION; -use const T_GLOBAL; -use const T_GOTO; -use const T_HALT_COMPILER; -use const T_IF; -use const T_IMPLEMENTS; -use const T_INCLUDE; -use const T_INCLUDE_ONCE; -use const T_INLINE_HTML; -use const T_INSTANCEOF; -use const T_INSTEADOF; -use const T_INTERFACE; -use const T_ISSET; -use const T_LIST; -use const T_MATCH; -use const T_NAMESPACE; -use const T_NEW; -use const T_PRINT; -use const T_PRIVATE; -use const T_PROTECTED; -use const T_PUBLIC; -use const T_READONLY; -use const T_REQUIRE; -use const T_REQUIRE_ONCE; -use const T_RETURN; -use const T_STATIC; -use const T_SWITCH; -use const T_THROW; -use const T_TRAIT; -use const T_TRY; -use const T_UNSET; -use const T_USE; -use const T_VAR; -use const T_WHILE; -use const T_YIELD; -use const T_YIELD_FROM; -use const TOKEN_PARSE; use function array_key_exists; use function array_keys; use function array_merge; @@ -91,17 +16,11 @@ use function array_unique; use function count; use function explode; -use function file_get_contents; use function htmlspecialchars; -use function is_string; use function ksort; use function range; use function sort; use function sprintf; -use function str_ends_with; -use function str_replace; -use function token_get_all; -use function trim; use SebastianBergmann\CodeCoverage\Data\ProcessedBranchCoverageData; use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; use SebastianBergmann\CodeCoverage\Data\ProcessedFunctionCoverageData; @@ -122,87 +41,6 @@ */ final class File extends Renderer { - /** - * @var array - */ - private const array KEYWORD_TOKENS = [ - T_ABSTRACT => true, - T_ARRAY => true, - T_AS => true, - T_BREAK => true, - T_CALLABLE => true, - T_CASE => true, - T_CATCH => true, - T_CLASS => true, - T_CLONE => true, - T_CONST => true, - T_CONTINUE => true, - T_DECLARE => true, - T_DEFAULT => true, - T_DO => true, - T_ECHO => true, - T_ELSE => true, - T_ELSEIF => true, - T_EMPTY => true, - T_ENDDECLARE => true, - T_ENDFOR => true, - T_ENDFOREACH => true, - T_ENDIF => true, - T_ENDSWITCH => true, - T_ENDWHILE => true, - T_ENUM => true, - T_EVAL => true, - T_EXIT => true, - T_EXTENDS => true, - T_FINAL => true, - T_FINALLY => true, - T_FN => true, - T_FOR => true, - T_FOREACH => true, - T_FUNCTION => true, - T_GLOBAL => true, - T_GOTO => true, - T_HALT_COMPILER => true, - T_IF => true, - T_IMPLEMENTS => true, - T_INCLUDE => true, - T_INCLUDE_ONCE => true, - T_INSTANCEOF => true, - T_INSTEADOF => true, - T_INTERFACE => true, - T_ISSET => true, - T_LIST => true, - T_MATCH => true, - T_NAMESPACE => true, - T_NEW => true, - T_PRINT => true, - T_PRIVATE => true, - T_PROTECTED => true, - T_PUBLIC => true, - T_READONLY => true, - T_REQUIRE => true, - T_REQUIRE_ONCE => true, - T_RETURN => true, - T_STATIC => true, - T_SWITCH => true, - T_THROW => true, - T_TRAIT => true, - T_TRY => true, - T_UNSET => true, - T_USE => true, - T_VAR => true, - T_WHILE => true, - T_YIELD => true, - T_YIELD_FROM => true, - ]; - - private const int HTML_SPECIAL_CHARS_FLAGS = ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE; - - /** - * @var array> - */ - private static array $formattedSourceCache = []; - public function render(FileNode $node, string $file): void { $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'file_branch.html' : 'file.html'); @@ -986,113 +824,6 @@ private function renderPathLines(ProcessedPathCoverageData $path, array $branche return $linesTemplate->render(); } - private function renderLine(Template $template, int $lineNumber, string $lineContent, string $class, string $popover): string - { - $template->setVar( - [ - 'lineNumber' => (string) $lineNumber, - 'lineContent' => $lineContent, - 'class' => $class, - 'popover' => $popover, - ], - ); - - return $template->render(); - } - - /** - * @param non-empty-string $file - * - * @return list - */ - private function loadFile(string $file): array - { - if (isset(self::$formattedSourceCache[$file])) { - return self::$formattedSourceCache[$file]; - } - - $buffer = file_get_contents($file); - $tokens = token_get_all($buffer, TOKEN_PARSE); - $result = ['']; - $i = 0; - $stringFlag = false; - $fileEndsWithNewLine = str_ends_with($buffer, "\n"); - - unset($buffer); - - foreach ($tokens as $j => $token) { - if (is_string($token)) { - if ($token === '"' && $tokens[$j - 1] !== '\\') { - $result[$i] .= sprintf( - '%s', - htmlspecialchars($token, self::HTML_SPECIAL_CHARS_FLAGS), - ); - - $stringFlag = !$stringFlag; - } else { - $result[$i] .= sprintf( - '%s', - htmlspecialchars($token, self::HTML_SPECIAL_CHARS_FLAGS), - ); - } - - continue; - } - - [$token, $value] = $token; - - $value = str_replace( - ["\t", ' '], - ['    ', ' '], - htmlspecialchars($value, self::HTML_SPECIAL_CHARS_FLAGS), - ); - - if ($value === "\n") { - $result[++$i] = ''; - } else { - $lines = explode("\n", $value); - - foreach ($lines as $jj => $line) { - $line = trim($line); - - if ($line !== '') { - if ($stringFlag) { - $colour = 'string'; - } else { - $colour = 'default'; - - if ($this->isInlineHtml($token)) { - $colour = 'html'; - } elseif ($this->isComment($token)) { - $colour = 'comment'; - } elseif ($this->isKeyword($token)) { - $colour = 'keyword'; - } - } - - $result[$i] .= sprintf( - '%s', - $colour, - $line, - ); - } - - if (isset($lines[$jj + 1])) { - $result[++$i] = ''; - } - } - } - } - - if ($fileEndsWithNewLine) { - unset($result[count($result) - 1]); - } - - self::$formattedSourceCache[$file] = $result; - - return $result; - } - private function abbreviateClassName(string $className): string { $tmp = explode('\\', $className); @@ -1118,47 +849,4 @@ private function abbreviateMethodName(string $methodName): string return $methodName; } - - private function createPopoverContentForTest(string $test, array $testData): string - { - $testCSS = ''; - - switch ($testData['status']) { - case 'success': - $testCSS = match ($testData['size']) { - 'small' => ' class="covered-by-small-tests"', - 'medium' => ' class="covered-by-medium-tests"', - // no break - default => ' class="covered-by-large-tests"', - }; - - break; - - case 'failure': - $testCSS = ' class="danger"'; - - break; - } - - return sprintf( - '%s', - $testCSS, - htmlspecialchars($test, self::HTML_SPECIAL_CHARS_FLAGS), - ); - } - - private function isComment(int $token): bool - { - return $token === T_COMMENT || $token === T_DOC_COMMENT; - } - - private function isInlineHtml(int $token): bool - { - return $token === T_INLINE_HTML; - } - - private function isKeyword(int $token): bool - { - return isset(self::KEYWORD_TOKENS[$token]); - } } diff --git a/src/Report/Html/Renderer/Template/class.html.dist b/src/Report/Html/Renderer/Template/class.html.dist new file mode 100644 index 000000000..39ed67153 --- /dev/null +++ b/src/Report/Html/Renderer/Template/class.html.dist @@ -0,0 +1,64 @@ + + + + + Code Coverage for {{full_path}} + + + + + + + +
+
+
+
+ +{{view_switcher}} +
+
+
+
+
+
+ + + + + + + + + + + + + + +{{items}} + +
 
Code Coverage
 
Lines
Methods
Classes
+
+{{sections}} + +
+ + + + + diff --git a/src/Report/Html/Renderer/Template/class_branch.html.dist b/src/Report/Html/Renderer/Template/class_branch.html.dist new file mode 100644 index 000000000..dc39c7419 --- /dev/null +++ b/src/Report/Html/Renderer/Template/class_branch.html.dist @@ -0,0 +1,66 @@ + + + + + Code Coverage for {{full_path}} + + + + + + + +
+
+
+
+ +{{view_switcher}} +
+
+
+
+
+
+ + + + + + + + + + + + + + + + +{{items}} + +
 
Code Coverage
 
Lines
Branches
Paths
Methods
Classes
+
+{{sections}} + +
+ + + + + diff --git a/src/Report/Html/Renderer/Template/class_item.html.dist b/src/Report/Html/Renderer/Template/class_item.html.dist new file mode 100644 index 000000000..b1c0fca48 --- /dev/null +++ b/src/Report/Html/Renderer/Template/class_item.html.dist @@ -0,0 +1,14 @@ + + {{name}} + {{lines_bar}} +
{{lines_executed_percent}}
+
{{lines_number}}
+ {{methods_bar}} +
{{methods_tested_percent}}
+
{{methods_number}}
+ {{crap}} + {{classes_bar}} +
{{classes_tested_percent}}
+
{{classes_number}}
+ + diff --git a/src/Report/Html/Renderer/Template/class_item_branch.html.dist b/src/Report/Html/Renderer/Template/class_item_branch.html.dist new file mode 100644 index 000000000..505025179 --- /dev/null +++ b/src/Report/Html/Renderer/Template/class_item_branch.html.dist @@ -0,0 +1,20 @@ + + {{name}} + {{lines_bar}} +
{{lines_executed_percent}}
+
{{lines_number}}
+ {{branches_bar}} +
{{branches_executed_percent}}
+
{{branches_number}}
+ {{paths_bar}} +
{{paths_executed_percent}}
+
{{paths_number}}
+ {{methods_bar}} +
{{methods_tested_percent}}
+
{{methods_number}}
+ {{crap}} + {{classes_bar}} +
{{classes_tested_percent}}
+
{{classes_number}}
+ + diff --git a/src/Report/Html/Renderer/Template/dashboard.html.dist b/src/Report/Html/Renderer/Template/dashboard.html.dist index d6575bc24..8b911717b 100644 --- a/src/Report/Html/Renderer/Template/dashboard.html.dist +++ b/src/Report/Html/Renderer/Template/dashboard.html.dist @@ -19,6 +19,7 @@ {{breadcrumbs}} +{{view_switcher}} diff --git a/src/Report/Html/Renderer/Template/dashboard_branch.html.dist b/src/Report/Html/Renderer/Template/dashboard_branch.html.dist index eb003154a..ee99ff58b 100644 --- a/src/Report/Html/Renderer/Template/dashboard_branch.html.dist +++ b/src/Report/Html/Renderer/Template/dashboard_branch.html.dist @@ -19,6 +19,7 @@ {{breadcrumbs}} +{{view_switcher}} diff --git a/src/Report/Html/Renderer/Template/directory.html.dist b/src/Report/Html/Renderer/Template/directory.html.dist index f769d2cae..e54df188b 100644 --- a/src/Report/Html/Renderer/Template/directory.html.dist +++ b/src/Report/Html/Renderer/Template/directory.html.dist @@ -19,6 +19,7 @@ {{breadcrumbs}} +{{view_switcher}} diff --git a/src/Report/Html/Renderer/Template/directory_branch.html.dist b/src/Report/Html/Renderer/Template/directory_branch.html.dist index a40c2e128..45681fcfb 100644 --- a/src/Report/Html/Renderer/Template/directory_branch.html.dist +++ b/src/Report/Html/Renderer/Template/directory_branch.html.dist @@ -19,6 +19,7 @@ {{breadcrumbs}} +{{view_switcher}} diff --git a/src/Report/Html/Renderer/Template/file.html.dist b/src/Report/Html/Renderer/Template/file.html.dist index d29103481..c580fbbca 100644 --- a/src/Report/Html/Renderer/Template/file.html.dist +++ b/src/Report/Html/Renderer/Template/file.html.dist @@ -19,6 +19,7 @@ {{breadcrumbs}} +{{view_switcher}} diff --git a/src/Report/Html/Renderer/Template/file_branch.html.dist b/src/Report/Html/Renderer/Template/file_branch.html.dist index b8bcf3747..6c6b83212 100644 --- a/src/Report/Html/Renderer/Template/file_branch.html.dist +++ b/src/Report/Html/Renderer/Template/file_branch.html.dist @@ -19,6 +19,7 @@ {{breadcrumbs}} +{{view_switcher}} diff --git a/src/Report/Html/Renderer/Template/namespace.html.dist b/src/Report/Html/Renderer/Template/namespace.html.dist new file mode 100644 index 000000000..07ee93263 --- /dev/null +++ b/src/Report/Html/Renderer/Template/namespace.html.dist @@ -0,0 +1,61 @@ + + + + + Code Coverage for {{full_path}} + + + + + + + +
+
+
+
+ +{{view_switcher}} +
+
+
+
+
+
+ + + + + + + + + + + + + + +{{items}} + +
 
Code Coverage
 
Lines
Methods
Classes
+
+
+
+

Legend

+

+ Low: 0% to {{low_upper_bound}}% + Medium: {{low_upper_bound}}% to {{high_lower_bound}}% + High: {{high_lower_bound}}% to 100% +

+

+ Generated by php-code-coverage {{version}} using {{runtime}}{{generator}} at {{date}}. +

+
+
+ + diff --git a/src/Report/Html/Renderer/Template/namespace_branch.html.dist b/src/Report/Html/Renderer/Template/namespace_branch.html.dist new file mode 100644 index 000000000..f13deeca5 --- /dev/null +++ b/src/Report/Html/Renderer/Template/namespace_branch.html.dist @@ -0,0 +1,63 @@ + + + + + Code Coverage for {{full_path}} + + + + + + + +
+
+
+
+ +{{view_switcher}} +
+
+
+
+
+
+ + + + + + + + + + + + + + + + +{{items}} + +
 
Code Coverage
 
Lines
Branches
Paths
Methods
Classes
+
+
+
+

Legend

+

+ Low: 0% to {{low_upper_bound}}% + Medium: {{low_upper_bound}}% to {{high_lower_bound}}% + High: {{high_lower_bound}}% to 100% +

+

+ Generated by php-code-coverage {{version}} using {{runtime}}{{generator}} at {{date}}. +

+
+
+ + diff --git a/src/Report/Html/Renderer/Template/namespace_item.html.dist b/src/Report/Html/Renderer/Template/namespace_item.html.dist new file mode 100644 index 000000000..f6941a437 --- /dev/null +++ b/src/Report/Html/Renderer/Template/namespace_item.html.dist @@ -0,0 +1,13 @@ + + {{icon}}{{name}} + {{lines_bar}} +
{{lines_executed_percent}}
+
{{lines_number}}
+ {{methods_bar}} +
{{methods_tested_percent}}
+
{{methods_number}}
+ {{classes_bar}} +
{{classes_tested_percent}}
+
{{classes_number}}
+ + diff --git a/src/Report/Html/Renderer/Template/namespace_item_branch.html.dist b/src/Report/Html/Renderer/Template/namespace_item_branch.html.dist new file mode 100644 index 000000000..532a436c2 --- /dev/null +++ b/src/Report/Html/Renderer/Template/namespace_item_branch.html.dist @@ -0,0 +1,19 @@ + + {{icon}}{{name}} + {{lines_bar}} +
{{lines_executed_percent}}
+
{{lines_number}}
+ {{branches_bar}} +
{{branches_executed_percent}}
+
{{branches_number}}
+ {{paths_bar}} +
{{paths_executed_percent}}
+
{{paths_number}}
+ {{methods_bar}} +
{{methods_tested_percent}}
+
{{methods_number}}
+ {{classes_bar}} +
{{classes_tested_percent}}
+
{{classes_number}}
+ + diff --git a/src/Report/Html/Renderer/Template/section_header.html.dist b/src/Report/Html/Renderer/Template/section_header.html.dist new file mode 100644 index 000000000..efea6397f --- /dev/null +++ b/src/Report/Html/Renderer/Template/section_header.html.dist @@ -0,0 +1 @@ +

{{title}}

diff --git a/tests/_files/ClassView/ChildClass.php b/tests/_files/ClassView/ChildClass.php new file mode 100644 index 000000000..bbf0a391d --- /dev/null +++ b/tests/_files/ClassView/ChildClass.php @@ -0,0 +1,13 @@ + + + diff --git a/tests/_files/Report/HTML/CoverageForBankAccount/_classes/BankAccount.html b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/BankAccount.html new file mode 100644 index 000000000..bfc266bc0 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/BankAccount.html @@ -0,0 +1,225 @@ + + + + + Code Coverage for BankAccount + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
+
+ 62.50% covered (warning) +
+
+
62.50%
5 / 8
+
+ 75.00% covered (warning) +
+
+
75.00%
3 / 4
CRAP
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
 getBalance
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
 setBalance
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 3
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
6
 depositMoney
+
+ 100.00% covered (success) +
+
+
100.00%
2 / 2
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
 withdrawMoney
+
+ 100.00% covered (success) +
+
+
100.00%
2 / 2
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2class BankAccount
3{
4    protected $balance = 0;
5
6    public function getBalance()
7    {
8        return $this->balance;
9    }
10
11    protected function setBalance($balance)
12    {
13        if ($balance >= 0) {
14            $this->balance = $balance;
15        } else {
16            throw new RuntimeException;
17        }
18    }
19
20    public function depositMoney($balance)
21    {
22        $this->setBalance($this->getBalance() + $balance);
23
24        return $this->getBalance();
25    }
26
27    public function withdrawMoney($balance)
28    {
29        $this->setBalance($this->getBalance() - $balance);
30
31        return $this->getBalance();
32        return $this->getBalance();
33    }
34}
+ +
+
+

Legend

+

Covered by small (and larger) testsCovered by medium (and large) testsCovered by large tests (and tests of unknown size)Not coveredNot coverable

+

+ Generated by php-code-coverage %s using %s at %s. +

+ + + +
+
+ + + + + diff --git a/tests/_files/Report/HTML/CoverageForBankAccount/_classes/dashboard.html b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/dashboard.html new file mode 100644 index 000000000..764d70190 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/dashboard.html @@ -0,0 +1,310 @@ + + + + + Dashboard for (Global) + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+
+
+
+
+

Coverage Distribution

+
+ +
+
+
+

Complexity

+
+ +
+
+
+
+
+

Insufficient Coverage

+
+ + + + + + + + + + + +
ClassCoverage
BankAccount62%
+
+
+
+

Project Risks

+
+ + + + + + + + + + + + + +
ClassCoverageComplexityCRAP
BankAccount62.5%56
+
+
+
+
+
+

Methods

+
+
+
+
+

Coverage Distribution

+
+ +
+
+
+

Complexity

+
+ +
+
+
+
+
+

Insufficient Coverage

+
+ + + + + + + + + + + +
MethodCoverage
setBalance0%
+
+
+
+

Project Risks

+
+ + + + + + + + + + + + + +
MethodCoverageComplexityCRAP
setBalance0.0%26
+
+
+
+ +
+ + + + + + diff --git a/tests/_files/Report/HTML/CoverageForBankAccount/_classes/index.html b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/index.html new file mode 100644 index 000000000..0bf656688 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/index.html @@ -0,0 +1,123 @@ + + + + + Code Coverage for (Global) + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
+
+ 62.50% covered (warning) +
+
+
62.50%
5 / 8
+
+ 75.00% covered (warning) +
+
+
75.00%
3 / 4
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
BankAccount
+
+ 62.50% covered (warning) +
+
+
62.50%
5 / 8
+
+ 75.00% covered (warning) +
+
+
75.00%
3 / 4
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+
+
+

Legend

+

+ Low: 0% to 50% + Medium: 50% to 90% + High: 90% to 100% +

+

+ Generated by php-code-coverage %s using %s at %s. +

+
+
+ + diff --git a/tests/_files/Report/HTML/CoverageForBankAccount/dashboard.html b/tests/_files/Report/HTML/CoverageForBankAccount/dashboard.html index 48a7ac744..251863352 100644 --- a/tests/_files/Report/HTML/CoverageForBankAccount/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForBankAccount/dashboard.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/CoverageForBankAccount/index.html b/tests/_files/Report/HTML/CoverageForBankAccount/index.html index 1112add13..a454df6b4 100644 --- a/tests/_files/Report/HTML/CoverageForBankAccount/index.html +++ b/tests/_files/Report/HTML/CoverageForBankAccount/index.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/CoveredClassWithAnonymousFunctionInStaticMethod.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/CoveredClassWithAnonymousFunctionInStaticMethod.html new file mode 100644 index 000000000..fc77b67c3 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/CoveredClassWithAnonymousFunctionInStaticMethod.html @@ -0,0 +1,143 @@ + + + + + Code Coverage for CoveredClassWithAnonymousFunctionInStaticMethod + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
+
+ 100.00% covered (success) +
+
+
100.00%
8 / 8
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
CRAP
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
 runAnonymous
+
+ 100.00% covered (success) +
+
+
100.00%
8 / 8
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
+
+ + + + + + + + + + + + + + + + + + + + + +
3class CoveredClassWithAnonymousFunctionInStaticMethod
4{
5    public static function runAnonymous()
6    {
7        $filter = ['abc124', 'abc123', '123'];
8
9        array_walk(
10            $filter,
11            function (&$val, $key) {
12                $val = preg_replace('|[^0-9]|', '', $val);
13            }
14        );
15
16        // Should be covered
17        $extravar = true;
18    }
19}
+ +
+
+

Legend

+

Covered by small (and larger) testsCovered by medium (and large) testsCovered by large tests (and tests of unknown size)Not coveredNot coverable

+

+ Generated by php-code-coverage %s using %s at %s. +

+ + + +
+
+ + + + + diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html new file mode 100644 index 000000000..ccd109b5f --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html @@ -0,0 +1,306 @@ + + + + + Dashboard for (Global) + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+
+
+
+
+

Coverage Distribution

+
+ +
+
+
+

Complexity

+
+ +
+
+
+
+
+

Insufficient Coverage

+
+ + + + + + + + + + +
ClassCoverage
+
+
+
+

Project Risks

+
+ + + + + + + + + + + + +
ClassCoverageComplexityCRAP
+
+
+
+
+
+

Methods

+
+
+
+
+

Coverage Distribution

+
+ +
+
+
+

Complexity

+
+ +
+
+
+
+
+

Insufficient Coverage

+
+ + + + + + + + + + +
MethodCoverage
+
+
+
+

Project Risks

+
+ + + + + + + + + + + + +
MethodCoverageComplexityCRAP
+
+
+
+ +
+ + + + + + diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/index.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/index.html new file mode 100644 index 000000000..b5db65143 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/index.html @@ -0,0 +1,123 @@ + + + + + Code Coverage for (Global) + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
+
+ 100.00% covered (success) +
+
+
100.00%
8 / 8
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
CoveredClassWithAnonymousFunctionInStaticMethod
+
+ 100.00% covered (success) +
+
+
100.00%
8 / 8
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+
+
+

Legend

+

+ Low: 0% to 50% + Medium: 50% to 90% + High: 90% to 100% +

+

+ Generated by php-code-coverage %s using %s at %s. +

+
+
+ + diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/dashboard.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/dashboard.html index 37fce2d95..ba0252b50 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/dashboard.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html index ff7813e53..4876661fe 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html index ff821b35d..3b972cce9 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Bar.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Bar.html new file mode 100644 index 000000000..3228527d9 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Bar.html @@ -0,0 +1,110 @@ + + + + + Code Coverage for Bar + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
n/a
0 / 0
n/a
0 / 0
CRAP
n/a
0 / 0
 foo
n/a
0 / 0
n/a
0 / 0
1
+
+ + + + + + + + + + + + + +
18class Bar
19{
20    /**
21     * @codeCoverageIgnore
22     */
23    public function foo()
24    {
25    }
26}
+ +
+
+

Legend

+

Covered by small (and larger) testsCovered by medium (and large) testsCovered by large tests (and tests of unknown size)Not coveredNot coverable

+

+ Generated by php-code-coverage %s using %s at %s. +

+ + + +
+
+ + + + + diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Foo.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Foo.html new file mode 100644 index 000000000..ddb0f4034 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Foo.html @@ -0,0 +1,107 @@ + + + + + Code Coverage for Foo + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
n/a
0 / 0
n/a
0 / 0
CRAP
n/a
0 / 0
 bar
n/a
0 / 0
n/a
0 / 0
1
+
+ + + + + + + + + + +
11class Foo
12{
13    public function bar()
14    {
15    }
16}
+ +
+
+

Legend

+

Covered by small (and larger) testsCovered by medium (and large) testsCovered by large tests (and tests of unknown size)Not coveredNot coverable

+

+ Generated by php-code-coverage %s using %s at %s. +

+ + + +
+
+ + + + + diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/dashboard.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/dashboard.html new file mode 100644 index 000000000..e56d7fae3 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/dashboard.html @@ -0,0 +1,306 @@ + + + + + Dashboard for (Global) + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+
+
+
+
+

Coverage Distribution

+
+ +
+
+
+

Complexity

+
+ +
+
+
+
+
+

Insufficient Coverage

+
+ + + + + + + + + + +
ClassCoverage
+
+
+
+

Project Risks

+
+ + + + + + + + + + + + +
ClassCoverageComplexityCRAP
+
+
+
+
+
+

Methods

+
+
+
+
+

Coverage Distribution

+
+ +
+
+
+

Complexity

+
+ +
+
+
+
+
+

Insufficient Coverage

+
+ + + + + + + + + + +
MethodCoverage
+
+
+
+

Project Risks

+
+ + + + + + + + + + + + +
MethodCoverageComplexityCRAP
+
+
+
+ +
+ + + + + + diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/index.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/index.html new file mode 100644 index 000000000..48d26a787 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/index.html @@ -0,0 +1,106 @@ + + + + + Code Coverage for (Global) + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
Foo
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
Bar
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
+
+
+
+

Legend

+

+ Low: 0% to 50% + Medium: 50% to 90% + High: 90% to 100% +

+

+ Generated by php-code-coverage %s using %s at %s. +

+
+
+ + diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/dashboard.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/dashboard.html index 4f6cc068a..ed7d451c6 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/dashboard.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html index 73c808041..a29a50211 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html index 5b61e5aed..823a1a36b 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html index 681941ba2..d8158ac09 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html index 681941ba2..d8158ac09 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html index 681941ba2..d8158ac09 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/BankAccount.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/BankAccount.html new file mode 100644 index 000000000..999ce4e8d --- /dev/null +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/BankAccount.html @@ -0,0 +1,307 @@ + + + + + Code Coverage for BankAccount + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Branches
Paths
Methods
Classes
Total
+
+ 62.50% covered (warning) +
+
+
62.50%
5 / 8
+
+ 42.86% covered (danger) +
+
+
42.86%
3 / 7
+
+ 60.00% covered (warning) +
+
+
60.00%
3 / 5
+
+ 75.00% covered (warning) +
+
+
75.00%
3 / 4
CRAP
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
 getBalance
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
 setBalance
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 3
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 4
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 2
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
6
 depositMoney
+
+ 100.00% covered (success) +
+
+
100.00%
2 / 2
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
 withdrawMoney
+
+ 100.00% covered (success) +
+
+
100.00%
2 / 2
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2class BankAccount
3{
4    protected $balance = 0;
5
6    public function getBalance()
7    {
8        return $this->balance;
9    }
10
11    protected function setBalance($balance)
12    {
13        if ($balance >= 0) {
14            $this->balance = $balance;
15        } else {
16            throw new RuntimeException;
17        }
18    }
19
20    public function depositMoney($balance)
21    {
22        $this->setBalance($this->getBalance() + $balance);
23
24        return $this->getBalance();
25    }
26
27    public function withdrawMoney($balance)
28    {
29        $this->setBalance($this->getBalance() - $balance);
30
31        return $this->getBalance();
32        return $this->getBalance();
33    }
34}
+ +
+
+

Legend

+

Covered by small (and larger) testsCovered by medium (and large) testsCovered by large tests (and tests of unknown size)Not coveredNot coverable

+

+ Generated by php-code-coverage %s using %s at %s. +

+ + + +
+
+ + + + + diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html new file mode 100644 index 000000000..fae593986 --- /dev/null +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html @@ -0,0 +1,304 @@ + + + + + Dashboard for (Global) + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+
+
+
+
+

Coverage Distribution

+
+ +
+
+
+

Complexity

+
+ +
+
+
+
+
+

Insufficient Coverage

+
+ + + + + + + + + + + +
ClassCoverage
BankAccount42%
+
+
+
+

Project Risks

+
+ + + + + + + + + + + +
ClassCRAP
BankAccount42.9%56
+
+
+
+
+
+

Methods

+
+
+
+
+

Coverage Distribution

+
+ +
+
+
+

Complexity

+
+ +
+
+
+
+
+

Insufficient Coverage

+
+ + + + + + + + + + + +
MethodCoverage
setBalance0%
+
+
+
+

Project Risks

+
+ + + + + + + + + + + +
MethodCRAP
setBalance0.0%26
+
+
+
+ +
+ + + + + + diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/index.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/index.html new file mode 100644 index 000000000..870dd4dd2 --- /dev/null +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/index.html @@ -0,0 +1,157 @@ + + + + + Code Coverage for (Global) + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Branches
Paths
Methods
Classes
Total
+
+ 62.50% covered (warning) +
+
+
62.50%
5 / 8
+
+ 42.86% covered (danger) +
+
+
42.86%
3 / 7
+
+ 60.00% covered (warning) +
+
+
60.00%
3 / 5
+
+ 75.00% covered (warning) +
+
+
75.00%
3 / 4
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
BankAccount
+
+ 62.50% covered (warning) +
+
+
62.50%
5 / 8
+
+ 42.86% covered (danger) +
+
+
42.86%
3 / 7
+
+ 60.00% covered (warning) +
+
+
60.00%
3 / 5
+
+ 75.00% covered (warning) +
+
+
75.00%
3 / 4
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+
+
+

Legend

+

+ Low: 0% to 50% + Medium: 50% to 90% + High: 90% to 100% +

+

+ Generated by php-code-coverage %s using %s at %s. +

+
+
+ + diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/dashboard.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/dashboard.html index dce9a9c67..856815dc1 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/dashboard.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/dashboard.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html index f4467d57c..920e22a32 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html new file mode 100644 index 000000000..e6d1f8f3a --- /dev/null +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html @@ -0,0 +1,100 @@ + + + + + Code Coverage for Foo + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Branches
Paths
Methods
Classes
Total
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
CRAP
n/a
0 / 0
+
+ + + + + + + +
5class Foo
6{
7}
+ +
+
+

Legend

+

Covered by small (and larger) testsCovered by medium (and large) testsCovered by large tests (and tests of unknown size)Not coveredNot coverable

+

+ Generated by php-code-coverage %s using %s at %s. +

+ + + +
+
+ + + + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html new file mode 100644 index 000000000..3ab4abe6e --- /dev/null +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html @@ -0,0 +1,300 @@ + + + + + Dashboard for (Global) + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+
+
+
+
+

Coverage Distribution

+
+ +
+
+
+

Complexity

+
+ +
+
+
+
+
+

Insufficient Coverage

+
+ + + + + + + + + + +
ClassCoverage
+
+
+
+

Project Risks

+
+ + + + + + + + + + +
ClassCRAP
+
+
+
+
+
+

Methods

+
+
+
+
+

Coverage Distribution

+
+ +
+
+
+

Complexity

+
+ +
+
+
+
+
+

Insufficient Coverage

+
+ + + + + + + + + + +
MethodCoverage
+
+
+
+

Project Risks

+
+ + + + + + + + + + +
MethodCRAP
+
+
+
+ +
+ + + + + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/index.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/index.html new file mode 100644 index 000000000..86e173eb4 --- /dev/null +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/index.html @@ -0,0 +1,107 @@ + + + + + Code Coverage for (Global) + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Branches
Paths
Methods
Classes
Total
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
Foo
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
+
+
+
+

Legend

+

+ Low: 0% to 50% + Medium: 50% to 90% + High: 90% to 100% +

+

+ Generated by php-code-coverage %s using %s at %s. +

+
+
+ + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/dashboard.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/dashboard.html index 9b0dfd33a..9cd991b14 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/dashboard.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/dashboard.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html index 51d43fe05..701024ee8 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html index 1446f796d..ae376b40c 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html index 590d2010b..f470fe77e 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html index 640b60b1f..37745afb8 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html @@ -21,6 +21,11 @@ + + diff --git a/tests/tests/Report/Html/ClassView/BuilderTest.php b/tests/tests/Report/Html/ClassView/BuilderTest.php new file mode 100644 index 000000000..ff45aefcc --- /dev/null +++ b/tests/tests/Report/Html/ClassView/BuilderTest.php @@ -0,0 +1,308 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use SebastianBergmann\CodeCoverage\Node\Directory; +use SebastianBergmann\CodeCoverage\Node\File; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\NamespaceNode; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Class_; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Method; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Trait_; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Visibility; + +#[CoversClass(Builder::class)] +#[Small] +final class BuilderTest extends TestCase +{ + public function testBuildWithSingleClass(): void + { + $root = $this->createDirectoryWithClass('App\\MyClass', 'App'); + + $builder = new Builder; + $result = $builder->build($root); + + $this->assertInstanceOf(NamespaceNode::class, $result); + $this->assertCount(1, $result->classes()); + $this->assertSame('App\\MyClass', $result->classes()[0]->className()); + } + + public function testBuildWithNestedNamespaces(): void + { + $root = new Directory('root'); + + $method = new Method('doSomething', 1, 5, 'public function doSomething(): void', Visibility::Public, 1); + $rawClass = new Class_('User', 'App\\Models\\User', 'App\\Models', '/path/to/User.php', 1, 20, null, [], [], ['doSomething' => $method]); + + $file = new File('User.php', $root, 'abc123', [], [], [], ['App\\Models\\User' => $rawClass], [], [], new LinesOfCode(20, 0, 20)); + $root->addFile($file); + + $builder = new Builder; + $result = $builder->build($root); + + $this->assertInstanceOf(NamespaceNode::class, $result); + + $allClasses = $result->allClassTypes(); + $this->assertArrayHasKey('App\\Models\\User', $allClasses); + } + + public function testBuildWithTraitsResolution(): void + { + $root = new Directory('root'); + + $traitMethod = new Method('traitMethod', 1, 5, 'public function traitMethod(): void', Visibility::Public, 1); + $rawTrait = new Trait_('MyTrait', 'App\\MyTrait', 'App', '/path/to/Trait.php', 1, 10, [], ['traitMethod' => $traitMethod]); + + $classMethod = new Method('doSomething', 10, 15, 'public function doSomething(): void', Visibility::Public, 1); + $rawClass = new Class_('MyClass', 'App\\MyClass', 'App', '/path/to/Class.php', 1, 20, null, [], ['App\\MyTrait'], ['doSomething' => $classMethod]); + + $traitFile = new File('Trait.php', $root, 'def456', [], [], [], [], ['App\\MyTrait' => $rawTrait], [], new LinesOfCode(10, 0, 10)); + $root->addFile($traitFile); + + $classFile = new File('Class.php', $root, 'abc123', [], [], [], ['App\\MyClass' => $rawClass], [], [], new LinesOfCode(20, 0, 20)); + $root->addFile($classFile); + + $builder = new Builder; + $result = $builder->build($root); + + $classes = []; + + foreach ($result->iterate() as $node) { + if ($node instanceof Node\ClassNode) { + $classes[] = $node; + } + } + + $this->assertNotEmpty($classes); + $classNode = $classes[0]; + $this->assertCount(1, $classNode->traitSections()); + $this->assertSame('App\\MyTrait', $classNode->traitSections()[0]->traitName); + } + + public function testBuildWithParentResolution(): void + { + $root = new Directory('root'); + + $parentMethod = new Method('parentMethod', 1, 5, 'public function parentMethod(): void', Visibility::Public, 1); + $rawParent = new Class_('ParentClass', 'App\\ParentClass', 'App', '/path/to/Parent.php', 1, 10, null, [], [], ['parentMethod' => $parentMethod]); + + $childMethod = new Method('childMethod', 1, 5, 'public function childMethod(): void', Visibility::Public, 1); + $rawChild = new Class_('ChildClass', 'App\\ChildClass', 'App', '/path/to/Child.php', 1, 20, 'App\\ParentClass', [], [], ['childMethod' => $childMethod]); + + $parentFile = new File('Parent.php', $root, 'abc123', [], [], [], ['App\\ParentClass' => $rawParent], [], [], new LinesOfCode(10, 0, 10)); + $root->addFile($parentFile); + + $childFile = new File('Child.php', $root, 'def456', [], [], [], ['App\\ChildClass' => $rawChild], [], [], new LinesOfCode(20, 0, 20)); + $root->addFile($childFile); + + $builder = new Builder; + $result = $builder->build($root); + + $classNodes = []; + + foreach ($result->iterate() as $node) { + if ($node instanceof Node\ClassNode) { + $classNodes[$node->className()] = $node; + } + } + + $this->assertArrayHasKey('App\\ChildClass', $classNodes); + $childNode = $classNodes['App\\ChildClass']; + $this->assertCount(1, $childNode->parentSections()); + $this->assertSame('App\\ParentClass', $childNode->parentSections()[0]->className); + } + + public function testBuildReducesRootWhenSingleChildNamespace(): void + { + $root = new Directory('root'); + + $method = new Method('m', 1, 5, 'public function m(): void', Visibility::Public, 1); + $rawClass = new Class_('MyClass', 'A\\B\\C\\MyClass', 'A\\B\\C', '/path/to/MyClass.php', 1, 20, null, [], [], ['m' => $method]); + + $file = new File('MyClass.php', $root, 'abc123', [], [], [], ['A\\B\\C\\MyClass' => $rawClass], [], [], new LinesOfCode(20, 0, 20)); + $root->addFile($file); + + $builder = new Builder; + $result = $builder->build($root); + + // Root should be reduced past empty namespace levels + $this->assertNull($result->parent()); + } + + public function testBuildDoesNotReduceRootWhenMultipleChildren(): void + { + $root = new Directory('root'); + + $methodA = new Method('m', 1, 5, 'public function m(): void', Visibility::Public, 1); + $methodB = new Method('m', 1, 5, 'public function m(): void', Visibility::Public, 1); + + $rawClassA = new Class_('ClassA', 'A\\ClassA', 'A', '/path/to/A.php', 1, 10, null, [], [], ['m' => $methodA]); + $rawClassB = new Class_('ClassB', 'B\\ClassB', 'B', '/path/to/B.php', 1, 10, null, [], [], ['m' => $methodB]); + + $fileA = new File('A.php', $root, 'abc123', [], [], [], ['A\\ClassA' => $rawClassA], [], [], new LinesOfCode(10, 0, 10)); + $root->addFile($fileA); + + $fileB = new File('B.php', $root, 'def456', [], [], [], ['B\\ClassB' => $rawClassB], [], [], new LinesOfCode(10, 0, 10)); + $root->addFile($fileB); + + $builder = new Builder; + $result = $builder->build($root); + + // Root should have two child namespaces since A and B are different + $this->assertCount(2, $result->childNamespaces()); + } + + public function testBuildWithGlobalNamespace(): void + { + $root = new Directory('root'); + + $method = new Method('m', 1, 5, 'public function m(): void', Visibility::Public, 1); + $rawClass = new Class_('GlobalClass', 'GlobalClass', '', '/path/to/Global.php', 1, 10, null, [], [], ['m' => $method]); + + $file = new File('Global.php', $root, 'abc123', [], [], [], ['GlobalClass' => $rawClass], [], [], new LinesOfCode(10, 0, 10)); + $root->addFile($file); + + $builder = new Builder; + $result = $builder->build($root); + + $this->assertCount(1, $result->classes()); + $this->assertSame('GlobalClass', $result->classes()[0]->className()); + } + + public function testBuildSkipsTraitsNotInRegistry(): void + { + $root = new Directory('root'); + + $classMethod = new Method('doSomething', 1, 5, 'public function doSomething(): void', Visibility::Public, 1); + $rawClass = new Class_('MyClass', 'App\\MyClass', 'App', '/path/to/Class.php', 1, 20, null, [], ['NonExistent\\Trait'], ['doSomething' => $classMethod]); + + $file = new File('Class.php', $root, 'abc123', [], [], [], ['App\\MyClass' => $rawClass], [], [], new LinesOfCode(20, 0, 20)); + $root->addFile($file); + + $builder = new Builder; + $result = $builder->build($root); + + $classes = []; + + foreach ($result->iterate() as $node) { + if ($node instanceof Node\ClassNode) { + $classes[] = $node; + } + } + + $this->assertNotEmpty($classes); + $this->assertCount(0, $classes[0]->traitSections()); + } + + public function testBuildSkipsParentsNotInRegistry(): void + { + $root = new Directory('root'); + + $method = new Method('m', 1, 5, 'public function m(): void', Visibility::Public, 1); + $rawClass = new Class_('MyClass', 'App\\MyClass', 'App', '/path/to/Class.php', 1, 20, 'NonExistent\\Parent', [], [], ['m' => $method]); + + $file = new File('Class.php', $root, 'abc123', [], [], [], ['App\\MyClass' => $rawClass], [], [], new LinesOfCode(20, 0, 20)); + $root->addFile($file); + + $builder = new Builder; + $result = $builder->build($root); + + $classes = []; + + foreach ($result->iterate() as $node) { + if ($node instanceof Node\ClassNode) { + $classes[] = $node; + } + } + + $this->assertNotEmpty($classes); + $this->assertCount(0, $classes[0]->parentSections()); + } + + public function testBuildInheritanceSkipsOverriddenMethods(): void + { + $root = new Directory('root'); + + $parentSharedMethod = new Method('sharedMethod', 1, 5, 'public function sharedMethod(): void', Visibility::Public, 1); + $rawParent = new Class_('ParentClass', 'App\\ParentClass', 'App', '/path/to/Parent.php', 1, 10, null, [], [], ['sharedMethod' => $parentSharedMethod]); + + // Child overrides sharedMethod + $childSharedMethod = new Method('sharedMethod', 1, 5, 'public function sharedMethod(): void', Visibility::Public, 1); + $rawChild = new Class_('ChildClass', 'App\\ChildClass', 'App', '/path/to/Child.php', 1, 20, 'App\\ParentClass', [], [], ['sharedMethod' => $childSharedMethod]); + + $parentFile = new File('Parent.php', $root, 'abc123', [], [], [], ['App\\ParentClass' => $rawParent], [], [], new LinesOfCode(10, 0, 10)); + $root->addFile($parentFile); + + $childFile = new File('Child.php', $root, 'def456', [], [], [], ['App\\ChildClass' => $rawChild], [], [], new LinesOfCode(20, 0, 20)); + $root->addFile($childFile); + + $builder = new Builder; + $result = $builder->build($root); + + $classNodes = []; + + foreach ($result->iterate() as $node) { + if ($node instanceof Node\ClassNode) { + $classNodes[$node->className()] = $node; + } + } + + $this->assertArrayHasKey('App\\ChildClass', $classNodes); + $childNode = $classNodes['App\\ChildClass']; + // sharedMethod is overridden, so no parent sections with inherited methods + $this->assertCount(0, $childNode->parentSections()); + } + + public function testBuildWithSubdirectory(): void + { + $root = new Directory('root'); + $subDir = $root->addDirectory('sub'); + + $method = new Method('m', 1, 5, 'public function m(): void', Visibility::Public, 1); + $rawClass = new Class_('MyClass', 'App\\MyClass', 'App', '/path/to/MyClass.php', 1, 10, null, [], [], ['m' => $method]); + + $file = new File('MyClass.php', $subDir, 'abc123', [], [], [], ['App\\MyClass' => $rawClass], [], [], new LinesOfCode(10, 0, 10)); + $subDir->addFile($file); + + $builder = new Builder; + $result = $builder->build($root); + + $allClasses = $result->allClassTypes(); + $this->assertArrayHasKey('App\\MyClass', $allClasses); + } + + public function testBuildCanBeCalledMultipleTimes(): void + { + $root = $this->createDirectoryWithClass('App\\MyClass', 'App'); + + $builder = new Builder; + $result1 = $builder->build($root); + $result2 = $builder->build($root); + + $this->assertCount(1, $result1->classes()); + $this->assertCount(1, $result2->classes()); + } + + private function createDirectoryWithClass(string $className, string $namespace): Directory + { + $root = new Directory('root'); + + $method = new Method('doSomething', 1, 5, 'public function doSomething(): void', Visibility::Public, 1); + $rawClass = new Class_('MyClass', $className, $namespace, '/path/to/Class.php', 1, 20, null, [], [], ['doSomething' => $method]); + + $file = new File('Class.php', $root, 'abc123', [], [], [], [$className => $rawClass], [], [], new LinesOfCode(20, 0, 20)); + $root->addFile($file); + + return $root; + } +} diff --git a/tests/tests/Report/Html/ClassView/Node/ClassNodeTest.php b/tests/tests/Report/Html/ClassView/Node/ClassNodeTest.php new file mode 100644 index 000000000..eba335c41 --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Node/ClassNodeTest.php @@ -0,0 +1,378 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; +use SebastianBergmann\CodeCoverage\Node\Directory; +use SebastianBergmann\CodeCoverage\Node\File; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; + +#[CoversClass(ClassNode::class)] +#[Small] +final class ClassNodeTest extends TestCase +{ + public function testClassName(): void + { + $node = $this->createClassNode(); + + $this->assertSame('App\\Models\\User', $node->className()); + } + + public function testShortName(): void + { + $node = $this->createClassNode(); + + $this->assertSame('User', $node->shortName()); + } + + public function testShortNameForNonNamespacedClass(): void + { + $parent = new NamespaceNode('Root', ''); + $node = $this->createClassNodeWithName('MyClass', '', $parent); + + $this->assertSame('MyClass', $node->shortName()); + } + + public function testNamespace(): void + { + $node = $this->createClassNode(); + + $this->assertSame('App\\Models', $node->namespace()); + } + + public function testFilePath(): void + { + $node = $this->createClassNode(); + + $this->assertSame('/path/to/User.php', $node->filePath()); + } + + public function testStartAndEndLine(): void + { + $node = $this->createClassNode(); + + $this->assertSame(10, $node->startLine()); + $this->assertSame(50, $node->endLine()); + } + + public function testClass(): void + { + $node = $this->createClassNode(); + + $this->assertInstanceOf(ProcessedClassType::class, $node->class_()); + } + + public function testFileNode(): void + { + $node = $this->createClassNode(); + + $this->assertInstanceOf(File::class, $node->fileNode()); + } + + public function testTraitSections(): void + { + $node = $this->createClassNodeWithTrait(); + + $this->assertCount(1, $node->traitSections()); + $this->assertSame('App\\MyTrait', $node->traitSections()[0]->traitName); + } + + public function testParentSections(): void + { + $node = $this->createClassNodeWithParent(); + + $this->assertCount(1, $node->parentSections()); + $this->assertSame('App\\BaseClass', $node->parentSections()[0]->className); + } + + public function testParent(): void + { + $node = $this->createClassNode(); + + $this->assertInstanceOf(NamespaceNode::class, $node->parent()); + } + + public function testAllMethodsIncludesOwnMethods(): void + { + $node = $this->createClassNode(); + $methods = $node->allMethods(); + + $this->assertArrayHasKey('doSomething', $methods); + } + + public function testAllMethodsIncludesTraitMethods(): void + { + $node = $this->createClassNodeWithTrait(); + $methods = $node->allMethods(); + + $this->assertArrayHasKey('[App\\MyTrait] traitMethod', $methods); + } + + public function testAllMethodsIncludesParentMethods(): void + { + $node = $this->createClassNodeWithParent(); + $methods = $node->allMethods(); + + $this->assertArrayHasKey('[App\\BaseClass] parentMethod', $methods); + } + + public function testNumberOfExecutableLinesIncludesTraitsAndParents(): void + { + $node = $this->createClassNodeWithTraitAndParent(); + + // Own: 10, trait: 5, parent method: 3 + $this->assertSame(18, $node->numberOfExecutableLines()); + } + + public function testNumberOfExecutedLinesIncludesTraitsAndParents(): void + { + $node = $this->createClassNodeWithTraitAndParent(); + + // Own: 7, trait: 3, parent method: 2 + $this->assertSame(12, $node->numberOfExecutedLines()); + } + + public function testNumberOfExecutableBranchesIncludesTraitsAndParents(): void + { + $node = $this->createClassNodeWithTraitAndParent(); + + // Own: 4, trait: 2, parent method: 1 + $this->assertSame(7, $node->numberOfExecutableBranches()); + } + + public function testNumberOfExecutedBranchesIncludesTraitsAndParents(): void + { + $node = $this->createClassNodeWithTraitAndParent(); + + // Own: 2, trait: 1, parent method: 0 + $this->assertSame(3, $node->numberOfExecutedBranches()); + } + + public function testNumberOfExecutablePathsIncludesTraitsAndParents(): void + { + $node = $this->createClassNodeWithTraitAndParent(); + + // Own: 6, trait: 3, parent method: 2 + $this->assertSame(11, $node->numberOfExecutablePaths()); + } + + public function testNumberOfExecutedPathsIncludesTraitsAndParents(): void + { + $node = $this->createClassNodeWithTraitAndParent(); + + // Own: 3, trait: 1, parent method: 1 + $this->assertSame(5, $node->numberOfExecutedPaths()); + } + + public function testNumberOfMethodsCountsOnlyMethodsWithExecutableLines(): void + { + $parent = new NamespaceNode('Root', ''); + $root = new Directory('root'); + + $methodWithLines = new ProcessedMethodType('a', 'public', 'public function a(): void', 1, 5, 3, 3, 0, 0, 0, 0, 1, 100, 1, ''); + $methodWithoutLines = new ProcessedMethodType('b', 'public', 'public function b(): void', 6, 10, 0, 0, 0, 0, 0, 0, 1, 0, 1, ''); + + $processedClass = new ProcessedClassType('MyClass', '', ['a' => $methodWithLines, 'b' => $methodWithoutLines], 1, 3, 3, 0, 0, 0, 0, 1, 100, 1, ''); + $fileNode = new File('test.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + $node = new ClassNode('MyClass', '', '/test.php', 1, 10, $processedClass, $fileNode, [], [], $parent); + + $this->assertSame(1, $node->numberOfMethods()); + } + + public function testNumberOfTestedMethodsCountsFullyCoveredOnly(): void + { + $parent = new NamespaceNode('Root', ''); + $root = new Directory('root'); + + $testedMethod = new ProcessedMethodType('a', 'public', 'public function a(): void', 1, 5, 3, 3, 0, 0, 0, 0, 1, 100, 1, ''); + $untestedMethod = new ProcessedMethodType('b', 'public', 'public function b(): void', 6, 10, 3, 1, 0, 0, 0, 0, 1, 33, 2, ''); + + $processedClass = new ProcessedClassType('MyClass', '', ['a' => $testedMethod, 'b' => $untestedMethod], 1, 6, 4, 0, 0, 0, 0, 2, 66, 2, ''); + $fileNode = new File('test.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + $node = new ClassNode('MyClass', '', '/test.php', 1, 10, $processedClass, $fileNode, [], [], $parent); + + $this->assertSame(1, $node->numberOfTestedMethods()); + } + + public function testNumberOfMethodsIsCached(): void + { + $node = $this->createClassNode(); + + $first = $node->numberOfMethods(); + $second = $node->numberOfMethods(); + + $this->assertSame($first, $second); + } + + public function testNumberOfTestedMethodsIsCached(): void + { + $node = $this->createClassNode(); + + $first = $node->numberOfTestedMethods(); + $second = $node->numberOfTestedMethods(); + + $this->assertSame($first, $second); + } + + public function testPercentageOfExecutedLines(): void + { + $node = $this->createClassNode(); + + $this->assertSame(70.0, $node->percentageOfExecutedLines()->asFloat()); + } + + public function testPercentageOfExecutedBranches(): void + { + $node = $this->createClassNode(); + + $this->assertSame(50.0, $node->percentageOfExecutedBranches()->asFloat()); + } + + public function testPercentageOfExecutedPaths(): void + { + $node = $this->createClassNode(); + + $this->assertSame(50.0, $node->percentageOfExecutedPaths()->asFloat()); + } + + public function testPercentageOfTestedMethods(): void + { + $node = $this->createClassNode(); + + // 0 out of 1 methods fully tested + $this->assertSame(0.0, $node->percentageOfTestedMethods()->asFloat()); + } + + public function testPercentageOfTestedClassesWhenFullyTested(): void + { + $parent = new NamespaceNode('Root', ''); + $root = new Directory('root'); + + $method = new ProcessedMethodType('m', 'public', 'public function m(): void', 1, 5, 3, 3, 0, 0, 0, 0, 1, 100, 1, ''); + $processedClass = new ProcessedClassType('MyClass', '', ['m' => $method], 1, 3, 3, 0, 0, 0, 0, 1, 100, 1, ''); + $fileNode = new File('test.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + $node = new ClassNode('MyClass', '', '/test.php', 1, 5, $processedClass, $fileNode, [], [], $parent); + + $this->assertSame(100.0, $node->percentageOfTestedClasses()->asFloat()); + } + + public function testPercentageOfTestedClassesWhenNotFullyTested(): void + { + $node = $this->createClassNode(); + + $this->assertSame(0.0, $node->percentageOfTestedClasses()->asFloat()); + } + + public function testPercentageOfTestedClassesWithNoMethods(): void + { + $parent = new NamespaceNode('Root', ''); + $root = new Directory('root'); + + $processedClass = new ProcessedClassType('MyClass', '', [], 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, ''); + $fileNode = new File('test.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + $node = new ClassNode('MyClass', '', '/test.php', 1, 5, $processedClass, $fileNode, [], [], $parent); + + // Empty string when no methods (0/0) + $this->assertSame('', $node->percentageOfTestedClasses()->asString()); + } + + private function createClassNode(): ClassNode + { + $parent = new NamespaceNode('Models', 'App\\Models'); + $root = new Directory('root'); + + $method = new ProcessedMethodType('doSomething', 'public', 'public function doSomething(): void', 10, 50, 10, 7, 4, 2, 6, 3, 2, 70, 2, ''); + + $processedClass = new ProcessedClassType('App\\Models\\User', 'App\\Models', ['doSomething' => $method], 10, 10, 7, 4, 2, 6, 3, 2, 70, 2, ''); + + $fileNode = new File('User.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + return new ClassNode('App\\Models\\User', 'App\\Models', '/path/to/User.php', 10, 50, $processedClass, $fileNode, [], [], $parent); + } + + private function createClassNodeWithName(string $className, string $namespace, NamespaceNode $parent): ClassNode + { + $root = new Directory('root'); + + $method = new ProcessedMethodType('m', 'public', 'public function m(): void', 1, 5, 3, 2, 0, 0, 0, 0, 1, 66, 1, ''); + $processedClass = new ProcessedClassType($className, $namespace, ['m' => $method], 1, 3, 2, 0, 0, 0, 0, 1, 66, 1, ''); + $fileNode = new File('test.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + return new ClassNode($className, $namespace, '/test.php', 1, 10, $processedClass, $fileNode, [], [], $parent); + } + + private function createClassNodeWithTrait(): ClassNode + { + $parent = new NamespaceNode('Root', ''); + $root = new Directory('root'); + + $ownMethod = new ProcessedMethodType('doSomething', 'public', 'public function doSomething(): void', 1, 5, 10, 7, 4, 2, 6, 3, 1, 70, 1, ''); + $traitMethod = new ProcessedMethodType('traitMethod', 'public', 'public function traitMethod(): void', 1, 5, 5, 3, 2, 1, 3, 1, 1, 60, 1, ''); + + $processedClass = new ProcessedClassType('App\\MyClass', 'App', ['doSomething' => $ownMethod], 1, 10, 7, 4, 2, 6, 3, 1, 70, 1, ''); + $processedTrait = new ProcessedTraitType('App\\MyTrait', 'App', ['traitMethod' => $traitMethod], 1, 5, 3, 2, 1, 3, 1, 1, 60, 1, ''); + + $fileNode = new File('test.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + $traitFileNode = new File('trait.php', $root, 'def456', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + $traitSection = new TraitSection('App\\MyTrait', '/path/to/trait.php', 1, 10, $processedTrait, $traitFileNode); + + return new ClassNode('App\\MyClass', 'App', '/path/to/class.php', 1, 20, $processedClass, $fileNode, [$traitSection], [], $parent); + } + + private function createClassNodeWithParent(): ClassNode + { + $parent = new NamespaceNode('Root', ''); + $root = new Directory('root'); + + $ownMethod = new ProcessedMethodType('doSomething', 'public', 'public function doSomething(): void', 1, 5, 10, 7, 4, 2, 6, 3, 1, 70, 1, ''); + $inheritedMethod = new ProcessedMethodType('parentMethod', 'public', 'public function parentMethod(): void', 1, 5, 3, 2, 1, 0, 2, 1, 1, 66, 1, ''); + + $processedClass = new ProcessedClassType('App\\MyClass', 'App', ['doSomething' => $ownMethod], 1, 10, 7, 4, 2, 6, 3, 1, 70, 1, ''); + + $fileNode = new File('test.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + $parentFileNode = new File('parent.php', $root, 'ghi789', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + $parentSection = new ParentSection('App\\BaseClass', '/path/to/base.php', ['parentMethod' => $inheritedMethod], $parentFileNode); + + return new ClassNode('App\\MyClass', 'App', '/path/to/class.php', 1, 20, $processedClass, $fileNode, [], [$parentSection], $parent); + } + + private function createClassNodeWithTraitAndParent(): ClassNode + { + $parent = new NamespaceNode('Root', ''); + $root = new Directory('root'); + + $ownMethod = new ProcessedMethodType('doSomething', 'public', 'public function doSomething(): void', 1, 5, 10, 7, 4, 2, 6, 3, 1, 70, 1, ''); + $traitMethod = new ProcessedMethodType('traitMethod', 'public', 'public function traitMethod(): void', 1, 5, 5, 3, 2, 1, 3, 1, 1, 60, 1, ''); + $inheritedMethod = new ProcessedMethodType('parentMethod', 'public', 'public function parentMethod(): void', 1, 5, 3, 2, 1, 0, 2, 1, 1, 66, 1, ''); + + $processedClass = new ProcessedClassType('App\\MyClass', 'App', ['doSomething' => $ownMethod], 1, 10, 7, 4, 2, 6, 3, 1, 70, 1, ''); + $processedTrait = new ProcessedTraitType('App\\MyTrait', 'App', ['traitMethod' => $traitMethod], 1, 5, 3, 2, 1, 3, 1, 1, 60, 1, ''); + + $fileNode = new File('test.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + $traitFileNode = new File('trait.php', $root, 'def456', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + $parentFileNode = new File('parent.php', $root, 'ghi789', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + $traitSection = new TraitSection('App\\MyTrait', '/path/to/trait.php', 1, 10, $processedTrait, $traitFileNode); + $parentSection = new ParentSection('App\\BaseClass', '/path/to/base.php', ['parentMethod' => $inheritedMethod], $parentFileNode); + + return new ClassNode('App\\MyClass', 'App', '/path/to/class.php', 1, 20, $processedClass, $fileNode, [$traitSection], [$parentSection], $parent); + } +} diff --git a/tests/tests/Report/Html/ClassView/Node/NamespaceNodeTest.php b/tests/tests/Report/Html/ClassView/Node/NamespaceNodeTest.php new file mode 100644 index 000000000..c272724c7 --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Node/NamespaceNodeTest.php @@ -0,0 +1,356 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node; + +use function iterator_to_array; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\Node\Directory; +use SebastianBergmann\CodeCoverage\Node\File; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; + +#[CoversClass(NamespaceNode::class)] +#[Small] +final class NamespaceNodeTest extends TestCase +{ + public function testNameAndNamespace(): void + { + $node = new NamespaceNode('Foo', 'App\\Foo'); + + $this->assertSame('Foo', $node->name()); + $this->assertSame('App\\Foo', $node->namespace()); + } + + public function testParentIsNullByDefault(): void + { + $node = new NamespaceNode('Root', ''); + + $this->assertNull($node->parent()); + } + + public function testParentCanBeSet(): void + { + $parent = new NamespaceNode('Root', ''); + $child = new NamespaceNode('Child', 'Child', $parent); + + $this->assertSame($parent, $child->parent()); + } + + public function testPromoteToRoot(): void + { + $parent = new NamespaceNode('Root', ''); + $child = new NamespaceNode('Child', 'Child', $parent); + + $child->promoteToRoot(); + + $this->assertNull($child->parent()); + } + + public function testIdForRootNode(): void + { + $root = new NamespaceNode('Root', ''); + $root->promoteToRoot(); + + $this->assertSame('index', $root->id()); + } + + public function testIdForDirectChild(): void + { + $root = new NamespaceNode('Root', ''); + $root->promoteToRoot(); + $child = new NamespaceNode('App', 'App', $root); + + $this->assertSame('App', $child->id()); + } + + public function testIdForNestedChild(): void + { + $root = new NamespaceNode('Root', ''); + $root->promoteToRoot(); + $child = new NamespaceNode('App', 'App', $root); + $nested = new NamespaceNode('Models', 'App\\Models', $child); + + $this->assertSame('App/Models', $nested->id()); + } + + public function testPathAsArrayForRoot(): void + { + $root = new NamespaceNode('Root', ''); + $root->promoteToRoot(); + + $path = $root->pathAsArray(); + + $this->assertCount(1, $path); + $this->assertSame($root, $path[0]); + } + + public function testPathAsArrayForNestedNode(): void + { + $root = new NamespaceNode('Root', ''); + $root->promoteToRoot(); + $child = new NamespaceNode('App', 'App', $root); + + $path = $child->pathAsArray(); + + $this->assertCount(2, $path); + $this->assertSame($root, $path[0]); + $this->assertSame($child, $path[1]); + } + + public function testAddNamespace(): void + { + $root = new NamespaceNode('Root', ''); + $child = new NamespaceNode('App', 'App', $root); + + $root->addNamespace($child); + + $this->assertCount(1, $root->childNamespaces()); + $this->assertSame($child, $root->childNamespaces()[0]); + } + + public function testAddClass(): void + { + $root = new NamespaceNode('Root', ''); + $classNode = $this->createClassNode($root); + + $root->addClass($classNode); + + $this->assertCount(1, $root->classes()); + $this->assertSame($classNode, $root->classes()[0]); + } + + public function testCountersWithClasses(): void + { + $root = new NamespaceNode('Root', ''); + $classNode = $this->createClassNode($root, 10, 5, 4, 2, 2, 1); + + $root->addClass($classNode); + + $this->assertSame(10, $root->numberOfExecutableLines()); + $this->assertSame(5, $root->numberOfExecutedLines()); + $this->assertSame(4, $root->numberOfExecutableBranches()); + $this->assertSame(2, $root->numberOfExecutedBranches()); + $this->assertSame(2, $root->numberOfExecutablePaths()); + $this->assertSame(1, $root->numberOfExecutedPaths()); + } + + public function testCountersAggregateChildNamespaces(): void + { + $root = new NamespaceNode('Root', ''); + $child = new NamespaceNode('Child', 'Child', $root); + + $classNode = $this->createClassNode($child, 10, 5, 4, 2, 2, 1); + $child->addClass($classNode); + $root->addNamespace($child); + + $this->assertSame(10, $root->numberOfExecutableLines()); + $this->assertSame(5, $root->numberOfExecutedLines()); + $this->assertSame(4, $root->numberOfExecutableBranches()); + $this->assertSame(2, $root->numberOfExecutedBranches()); + $this->assertSame(2, $root->numberOfExecutablePaths()); + $this->assertSame(1, $root->numberOfExecutedPaths()); + } + + public function testNumberOfClassesExcludesClassesWithNoMethods(): void + { + $root = new NamespaceNode('Root', ''); + $root->addClass($this->createClassNode($root, 0, 0, 0, 0, 0, 0, [])); + + $this->assertSame(0, $root->numberOfClasses()); + } + + public function testNumberOfClassesCountsClassesWithMethods(): void + { + $root = new NamespaceNode('Root', ''); + $root->addClass($this->createClassNode($root)); + + $this->assertSame(1, $root->numberOfClasses()); + } + + public function testNumberOfTestedClasses(): void + { + $root = new NamespaceNode('Root', ''); + + $testedMethod = new ProcessedMethodType('testedMethod', 'public', 'public function testedMethod(): void', 1, 5, 3, 3, 0, 0, 0, 0, 1, 100, 1, ''); + $root->addClass($this->createClassNode($root, 3, 3, 0, 0, 0, 0, ['testedMethod' => $testedMethod])); + + $this->assertSame(1, $root->numberOfTestedClasses()); + } + + public function testNumberOfTestedClassesExcludesPartialCoverage(): void + { + $root = new NamespaceNode('Root', ''); + $root->addClass($this->createClassNode($root)); + + $this->assertSame(0, $root->numberOfTestedClasses()); + } + + public function testNumberOfClassesAggregatesChildNamespaces(): void + { + $root = new NamespaceNode('Root', ''); + $child = new NamespaceNode('Child', 'Child', $root); + + $child->addClass($this->createClassNode($child)); + $root->addNamespace($child); + + $this->assertSame(1, $root->numberOfClasses()); + } + + public function testNumberOfTestedClassesAggregatesChildNamespaces(): void + { + $root = new NamespaceNode('Root', ''); + $child = new NamespaceNode('Child', 'Child', $root); + + $testedMethod = new ProcessedMethodType('m', 'public', 'public function m(): void', 1, 5, 3, 3, 0, 0, 0, 0, 1, 100, 1, ''); + $child->addClass($this->createClassNode($child, 3, 3, 0, 0, 0, 0, ['m' => $testedMethod])); + $root->addNamespace($child); + + $this->assertSame(1, $root->numberOfTestedClasses()); + } + + public function testNumberOfMethodsAggregatesChildNamespaces(): void + { + $root = new NamespaceNode('Root', ''); + $child = new NamespaceNode('Child', 'Child', $root); + + $child->addClass($this->createClassNode($child)); + $root->addNamespace($child); + + $this->assertSame(1, $root->numberOfMethods()); + } + + public function testNumberOfTestedMethodsAggregatesChildNamespaces(): void + { + $root = new NamespaceNode('Root', ''); + $child = new NamespaceNode('Child', 'Child', $root); + + $testedMethod = new ProcessedMethodType('m', 'public', 'public function m(): void', 1, 5, 3, 3, 0, 0, 0, 0, 1, 100, 1, ''); + $child->addClass($this->createClassNode($child, 3, 3, 0, 0, 0, 0, ['m' => $testedMethod])); + $root->addNamespace($child); + + $this->assertSame(1, $root->numberOfTestedMethods()); + } + + public function testPercentages(): void + { + $root = new NamespaceNode('Root', ''); + $root->addClass($this->createClassNode($root, 10, 5, 4, 2, 2, 1)); + + $this->assertSame(50.0, $root->percentageOfExecutedLines()->asFloat()); + $this->assertSame(50.0, $root->percentageOfExecutedBranches()->asFloat()); + $this->assertSame(50.0, $root->percentageOfExecutedPaths()->asFloat()); + } + + public function testPercentageOfTestedMethods(): void + { + $root = new NamespaceNode('Root', ''); + $testedMethod = new ProcessedMethodType('m', 'public', 'public function m(): void', 1, 5, 3, 3, 0, 0, 0, 0, 1, 100, 1, ''); + $untestedMethod = new ProcessedMethodType('n', 'public', 'public function n(): void', 6, 10, 3, 0, 0, 0, 0, 0, 1, 0, 1, ''); + + $root->addClass($this->createClassNode($root, 6, 3, 0, 0, 0, 0, ['m' => $testedMethod, 'n' => $untestedMethod])); + + $this->assertSame(50.0, $root->percentageOfTestedMethods()->asFloat()); + } + + public function testPercentageOfTestedClasses(): void + { + $root = new NamespaceNode('Root', ''); + + $testedMethod = new ProcessedMethodType('m', 'public', 'public function m(): void', 1, 5, 3, 3, 0, 0, 0, 0, 1, 100, 1, ''); + $root->addClass($this->createClassNode($root, 3, 3, 0, 0, 0, 0, ['m' => $testedMethod])); + $root->addClass($this->createClassNode($root, 3, 0, 0, 0, 0, 0)); + + $this->assertSame(50.0, $root->percentageOfTestedClasses()->asFloat()); + } + + public function testAllClassTypes(): void + { + $root = new NamespaceNode('Root', ''); + $child = new NamespaceNode('Child', 'Child', $root); + + $root->addClass($this->createClassNode($root)); + $child->addClass($this->createClassNode($child, 10, 5, 0, 0, 0, 0, null, 'App\\Other')); + $root->addNamespace($child); + + $result = $root->allClassTypes(); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('App\\MyClass', $result); + $this->assertArrayHasKey('App\\Other', $result); + } + + public function testIterate(): void + { + $root = new NamespaceNode('Root', ''); + $child = new NamespaceNode('Child', 'Child', $root); + + $rootClass = $this->createClassNode($root); + $childClass = $this->createClassNode($child, 10, 5, 0, 0, 0, 0, null, 'App\\Other'); + + $root->addClass($rootClass); + $child->addClass($childClass); + $root->addNamespace($child); + + $nodes = iterator_to_array($root->iterate(), false); + + $this->assertCount(3, $nodes); + $this->assertSame($child, $nodes[0]); + $this->assertSame($childClass, $nodes[1]); + $this->assertSame($rootClass, $nodes[2]); + } + + public function testCountersResetWhenAddingClass(): void + { + $root = new NamespaceNode('Root', ''); + $root->addClass($this->createClassNode($root, 10, 5, 0, 0, 0, 0)); + + $this->assertSame(10, $root->numberOfExecutableLines()); + + $root->addClass($this->createClassNode($root, 20, 10, 0, 0, 0, 0, null, 'App\\Other')); + + $this->assertSame(30, $root->numberOfExecutableLines()); + } + + public function testCountersResetWhenAddingNamespace(): void + { + $root = new NamespaceNode('Root', ''); + $child = new NamespaceNode('Child', 'Child', $root); + $child->addClass($this->createClassNode($child, 10, 5, 0, 0, 0, 0)); + + $this->assertSame(0, $root->numberOfExecutableLines()); + + $root->addNamespace($child); + + $this->assertSame(10, $root->numberOfExecutableLines()); + } + + /** + * @param null|array $methods + */ + private function createClassNode(NamespaceNode $parent, int $executableLines = 10, int $executedLines = 5, int $executableBranches = 0, int $executedBranches = 0, int $executablePaths = 0, int $executedPaths = 0, ?array $methods = null, string $className = 'App\\MyClass'): ClassNode + { + $root = new Directory('root'); + + if ($methods === null) { + $methods = [ + 'doSomething' => new ProcessedMethodType('doSomething', 'public', 'public function doSomething(): void', 1, 5, $executableLines, $executedLines, $executableBranches, $executedBranches, $executablePaths, $executedPaths, 1, $executableLines > 0 ? ($executedLines / $executableLines) * 100 : 0, 1, ''), + ]; + } + + $processedClass = new ProcessedClassType($className, 'App', $methods, 1, $executableLines, $executedLines, $executableBranches, $executedBranches, $executablePaths, $executedPaths, 1, $executableLines > 0 ? ($executedLines / $executableLines) * 100 : 0, 1, ''); + + $fileNode = new File('test.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + return new ClassNode($className, 'App', '/path/to/test.php', 1, 20, $processedClass, $fileNode, [], [], $parent); + } +} diff --git a/tests/tests/Report/Html/ClassView/Node/ParentSectionTest.php b/tests/tests/Report/Html/ClassView/Node/ParentSectionTest.php new file mode 100644 index 000000000..1dcce3174 --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Node/ParentSectionTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\Node\Directory; +use SebastianBergmann\CodeCoverage\Node\File; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; + +#[CoversClass(ParentSection::class)] +#[Small] +final class ParentSectionTest extends TestCase +{ + public function testProperties(): void + { + $root = new Directory('root'); + $fileNode = new File('parent.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + $method = new ProcessedMethodType('parentMethod', 'public', 'public function parentMethod(): void', 1, 5, 3, 2, 0, 0, 0, 0, 1, 66, 1, ''); + + $section = new ParentSection('App\\BaseClass', '/path/to/base.php', ['parentMethod' => $method], $fileNode); + + $this->assertSame('App\\BaseClass', $section->className); + $this->assertSame('/path/to/base.php', $section->filePath); + $this->assertArrayHasKey('parentMethod', $section->methods); + $this->assertSame($fileNode, $section->fileNode); + } +} diff --git a/tests/tests/Report/Html/ClassView/Node/TraitSectionTest.php b/tests/tests/Report/Html/ClassView/Node/TraitSectionTest.php new file mode 100644 index 000000000..a21d15600 --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Node/TraitSectionTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; +use SebastianBergmann\CodeCoverage\Node\Directory; +use SebastianBergmann\CodeCoverage\Node\File; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; + +#[CoversClass(TraitSection::class)] +#[Small] +final class TraitSectionTest extends TestCase +{ + public function testProperties(): void + { + $root = new Directory('root'); + $fileNode = new File('trait.php', $root, 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + $method = new ProcessedMethodType('traitMethod', 'public', 'public function traitMethod(): void', 1, 5, 3, 2, 0, 0, 0, 0, 1, 66, 1, ''); + $trait = new ProcessedTraitType('App\\MyTrait', 'App', ['traitMethod' => $method], 1, 3, 2, 0, 0, 0, 0, 1, 66, 1, ''); + + $section = new TraitSection('App\\MyTrait', '/path/to/trait.php', 1, 10, $trait, $fileNode); + + $this->assertSame('App\\MyTrait', $section->traitName); + $this->assertSame('/path/to/trait.php', $section->filePath); + $this->assertSame(1, $section->startLine); + $this->assertSame(10, $section->endLine); + $this->assertSame($trait, $section->trait); + $this->assertSame($fileNode, $section->fileNode); + } +} diff --git a/tests/tests/Report/Html/EndToEndTest.php b/tests/tests/Report/Html/EndToEndTest.php index 1c7a76312..37ce0da9f 100644 --- a/tests/tests/Report/Html/EndToEndTest.php +++ b/tests/tests/Report/Html/EndToEndTest.php @@ -12,6 +12,7 @@ use const DIRECTORY_SEPARATOR; use const PHP_EOL; use function file_get_contents; +use function is_dir; use function iterator_count; use function str_replace; use FilesystemIterator; @@ -77,8 +78,21 @@ public function testForClassWithAnonymousFunction(): void private function assertFilesEquals(string $expectedFilesPath, string $actualFilesPath): void { - $expectedFilesIterator = new FilesystemIterator($expectedFilesPath); - $actualFilesIterator = new RegexIterator(new FilesystemIterator($actualFilesPath), '/.html/'); + $this->assertHtmlFilesEquals($expectedFilesPath, $actualFilesPath); + + $expectedClassesPath = $expectedFilesPath . DIRECTORY_SEPARATOR . '_classes'; + $actualClassesPath = $actualFilesPath . DIRECTORY_SEPARATOR . '_classes'; + + if (is_dir($expectedClassesPath)) { + $this->assertDirectoryExists($actualClassesPath); + $this->assertHtmlFilesEquals($expectedClassesPath, $actualClassesPath); + } + } + + private function assertHtmlFilesEquals(string $expectedFilesPath, string $actualFilesPath): void + { + $expectedFilesIterator = new RegexIterator(new FilesystemIterator($expectedFilesPath), '/\.html$/'); + $actualFilesIterator = new RegexIterator(new FilesystemIterator($actualFilesPath), '/\.html$/'); $this->assertEquals( iterator_count($expectedFilesIterator), @@ -86,8 +100,7 @@ private function assertFilesEquals(string $expectedFilesPath, string $actualFile 'Generated files and expected files not match', ); - foreach ($expectedFilesIterator as $path => $fileInfo) { - /* @var \SplFileInfo $fileInfo */ + foreach (new RegexIterator(new FilesystemIterator($expectedFilesPath), '/\.html$/') as $path => $fileInfo) { $filename = $fileInfo->getFilename(); $actualFile = $actualFilesPath . DIRECTORY_SEPARATOR . $filename;