From 93779f63eb3337549422b0fc70c594df15bf302b Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Fri, 13 Mar 2026 19:32:29 +0100 Subject: [PATCH 01/12] Initial work on class-oriented HTML report --- src/Node/File.php | 28 ++ src/Report/Html/ClassView/Builder.php | 241 +++++++++++ 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 | 389 +++++++++++++++++ .../Html/ClassView/Renderer/Dashboard.php | 259 ++++++++++++ .../Html/ClassView/Renderer/Namespace_.php | 222 ++++++++++ src/Report/Html/Facade.php | 116 +++-- src/Report/Html/Renderer.php | 102 ++++- src/Report/Html/Renderer/File.php | 59 --- .../Html/Renderer/Template/class.html.dist | 64 +++ .../Renderer/Template/class_branch.html.dist | 65 +++ .../Template/class_branch_and_path.html.dist | 66 +++ .../Renderer/Template/class_item.html.dist | 14 + .../Template/class_item_branch.html.dist | 17 + .../class_item_branch_and_path.html.dist | 20 + .../Renderer/Template/dashboard.html.dist | 1 + .../Template/dashboard_branch.html.dist | 1 + .../dashboard_branch_and_path.html.dist | 1 + .../Renderer/Template/directory.html.dist | 1 + .../Template/directory_branch.html.dist | 1 + .../directory_branch_and_path.html.dist | 1 + .../Html/Renderer/Template/file.html.dist | 1 + .../Renderer/Template/file_branch.html.dist | 1 + .../Template/file_branch_and_path.html.dist | 1 + .../Renderer/Template/namespace.html.dist | 61 +++ .../Template/namespace_branch.html.dist | 62 +++ .../namespace_branch_and_path.html.dist | 63 +++ .../Template/namespace_item.html.dist | 13 + .../Template/namespace_item_branch.html.dist | 16 + .../namespace_item_branch_and_path.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 + tests/_files/FacadeNested/GlobalClass.php | 8 + .../FacadeNested/sub/HomeController.php | 10 + tests/_files/FacadeNested/sub/User.php | 10 + .../BankAccount.php.html | 64 +-- .../_classes/BankAccount.html | 172 ++++++++ .../_classes/dashboard.html | 173 ++++++++ .../_classes/index.html | 123 ++++++ .../CoverageForBankAccount/dashboard.html | 5 + .../HTML/CoverageForBankAccount/index.html | 7 +- ...ssWithAnonymousFunctionInStaticMethod.html | 106 +++++ .../_classes/dashboard.html | 163 +++++++ .../_classes/index.html | 123 ++++++ .../dashboard.html | 5 + .../index.html | 7 +- ...with_class_and_anonymous_function.php.html | 49 +-- .../_classes/Bar.html | 81 ++++ .../_classes/Foo.html | 81 ++++ .../_classes/dashboard.html | 97 +++++ .../_classes/index.html | 106 +++++ .../dashboard.html | 5 + .../index.html | 7 +- .../source_with_ignore.php.html | 72 +--- .../BankAccount.php.html | 7 +- .../BankAccount.php_branch.html | 7 +- .../BankAccount.php_path.html | 7 +- .../_classes/BankAccount.html | 254 +++++++++++ .../_classes/dashboard.html | 173 ++++++++ .../_classes/index.html | 157 +++++++ .../PathCoverageForBankAccount/dashboard.html | 5 + .../PathCoverageForBankAccount/index.html | 7 +- .../_classes/Foo.html | 77 ++++ .../_classes/dashboard.html | 94 +++++ .../_classes/index.html | 107 +++++ .../dashboard.html | 5 + .../index.html | 7 +- .../source_without_namespace.php.html | 48 +-- .../source_without_namespace.php_branch.html | 94 +---- .../source_without_namespace.php_path.html | 95 +---- tests/tests/Node/FileTest.php | 48 +++ .../Report/Html/ClassView/BuilderTest.php | 311 ++++++++++++++ .../Html/ClassView/Node/ClassNodeTest.php | 381 +++++++++++++++++ .../Html/ClassView/Node/NamespaceNodeTest.php | 357 ++++++++++++++++ .../Html/ClassView/Node/ParentSectionTest.php | 38 ++ .../Html/ClassView/Node/TraitSectionTest.php | 42 ++ .../Html/ClassView/Renderer/Class_Test.php | 386 +++++++++++++++++ .../Html/ClassView/Renderer/DashboardTest.php | 163 +++++++ .../ClassView/Renderer/Namespace_Test.php | 167 ++++++++ tests/tests/Report/Html/EndToEndTest.php | 18 +- tests/tests/Report/Html/FacadeTest.php | 193 +++++++++ 87 files changed, 7038 insertions(+), 477 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_branch_and_path.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/class_item_branch_and_path.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_branch_and_path.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/namespace_item_branch_and_path.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/FacadeNested/GlobalClass.php create mode 100644 tests/_files/FacadeNested/sub/HomeController.php create mode 100644 tests/_files/FacadeNested/sub/User.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 create mode 100644 tests/tests/Report/Html/ClassView/Renderer/Class_Test.php create mode 100644 tests/tests/Report/Html/ClassView/Renderer/DashboardTest.php create mode 100644 tests/tests/Report/Html/ClassView/Renderer/Namespace_Test.php create mode 100644 tests/tests/Report/Html/FacadeTest.php diff --git a/src/Node/File.php b/src/Node/File.php index 175346f50..1298425ee 100644 --- a/src/Node/File.php +++ b/src/Node/File.php @@ -92,6 +92,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 @@ -111,6 +121,8 @@ public function __construct(string $name, AbstractNode $parent, string $sha1, ar $this->testData = $testData; $this->linesOfCode = $linesOfCode; $this->hasBranchCoverageData = $hasBranchCoverageData; + $this->rawClasses = $classes; + $this->rawTraits = $traits; $this->calculateStatistics($classes, $traits, $functions); } @@ -152,6 +164,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..530e43855 --- /dev/null +++ b/src/Report/Html/ClassView/Builder.php @@ -0,0 +1,241 @@ + + * + * 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(); + $filePath = $entry['file']->pathAsString(); + + if ($filePath === '') { + continue; // @codeCoverageIgnore + } + + $parentNs = $this->ensureNamespaceExists($namespace, $namespaceMap, $rootNamespace); + + $traitSections = $this->resolveTraits($raw); + $parentSections = $this->resolveParents($raw); + + $classNode = new ClassNode( + $fqcn, + $namespace, + $filePath, + $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 ($className !== '' && array_key_exists($className, $node->classes())) { + $this->classRegistry[$className] = [ + 'file' => $node, + 'raw' => $rawClass, + 'processed' => $node->classes()[$className], + ]; + } + } + + foreach ($node->rawTraits() as $traitName => $rawTrait) { + if ($traitName !== '' && 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]; + $filePath = $entry['file']->pathAsString(); + + if ($filePath === '') { + continue; // @codeCoverageIgnore + } + + $sections[] = new TraitSection( + $traitName, + $filePath, + $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; + } + } + + $filePath = $parentEntry['file']->pathAsString(); + + if ($inheritedMethods !== [] && $filePath !== '') { + $sections[] = new ParentSection( + $parentName, + $filePath, + $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..d82909695 --- /dev/null +++ b/src/Report/Html/ClassView/Renderer/Class_.php @@ -0,0 +1,389 @@ + + * + * 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 + * + * @phpstan-import-type TestType from \SebastianBergmann\CodeCoverage\CodeCoverage + */ +final class Class_ extends Renderer +{ + public function render(ClassNode $node, string $file): void + { + $templateName = $this->templateNameForTier('class'); + $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->templateNameForTier('class_item'); + $template = new Template($templateName, '{{', '}}'); + + $methodTemplateName = $this->templateNameForTier('method_item'); + $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 non-empty-string $filePath + * @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->syntaxHighlighter->highlight($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..452d91190 --- /dev/null +++ b/src/Report/Html/ClassView/Renderer/Dashboard.php @@ -0,0 +1,259 @@ + + * + * 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 htmlspecialchars; +use function sprintf; +use function str_repeat; +use function str_replace; +use function substr_count; +use function usort; +use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; +use SebastianBergmann\CodeCoverage\Node\CrapIndex; +use SebastianBergmann\CodeCoverage\Report\Html\BubbleChart; +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->templateNameForTier('dashboard'); + $template = new Template($templateName, '{{', '}}'); + + $this->setCommonTemplateVariablesForNamespace($template, $node); + + $baseLink = $node->id() . '/'; + $bubbleChart = new BubbleChart($this->thresholds); + + $template->setVar( + [ + 'class_bubble_chart' => $bubbleChart->render($this->classItems($classes, $baseLink)), + 'class_crap_table' => $this->classCrapTable($classes, $baseLink), + 'method_bubble_chart' => $bubbleChart->render($this->methodItems($classes, $baseLink)), + 'method_crap_table' => $this->methodCrapTable($classes, $baseLink), + ], + ); + + 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; + } + + /** + * @param array $classes + * + * @return list + */ + private function classItems(array $classes, string $baseLink): array + { + $items = []; + + foreach ($classes as $className => $class) { + if ($class->executableLines === 0) { + continue; + } + + $items[] = [ + 'name' => $className, + 'coverage' => $class->coverage, + 'executableLines' => $class->executableLines, + 'complexity' => $class->ccn, + 'link' => str_replace($baseLink, '', $class->link), + ]; + } + + return $items; + } + + /** + * @param array $classes + * + * @return list + */ + private function methodItems(array $classes, string $baseLink): array + { + $items = []; + + foreach ($classes as $className => $class) { + foreach ($class->methods as $methodName => $method) { + if ($method->executableLines === 0) { + continue; + } + + $items[] = [ + 'name' => $className . '::' . $methodName, + 'coverage' => $method->coverage, + 'executableLines' => $method->executableLines, + 'complexity' => $method->ccn, + 'link' => str_replace($baseLink, '', $method->link), + ]; + } + } + + return $items; + } + + /** + * @param array $classes + */ + private function classCrapTable(array $classes, string $baseLink): string + { + $items = []; + + foreach ($classes as $className => $class) { + $items[] = [ + 'name' => $className, + 'coverage' => $class->coverage, + 'crap' => (new CrapIndex($class->ccn, $class->coverage))->asString(), + 'link' => str_replace($baseLink, '', $class->link), + ]; + } + + return $this->crapTable($items, 'Class'); + } + + /** + * @param array $classes + */ + private function methodCrapTable(array $classes, string $baseLink): string + { + $items = []; + + foreach ($classes as $className => $class) { + foreach ($class->methods as $methodName => $method) { + $items[] = [ + 'name' => $className . '::' . $methodName, + 'coverage' => $method->coverage, + 'crap' => (new CrapIndex($method->ccn, $method->coverage))->asString(), + 'link' => str_replace($baseLink, '', $method->link), + ]; + } + } + + return $this->crapTable($items, 'Method'); + } + + /** + * @param list $items + */ + private function crapTable(array $items, string $entityLabel): string + { + usort($items, static fn (array $a, array $b): int => ((float) $b['crap'] <=> (float) $a['crap'])); + + $html = '' . "\n"; + $html .= ' ' . "\n"; + $html .= ' ' . "\n"; + $html .= sprintf(' ' . "\n", $entityLabel); + $html .= ' ' . "\n"; + $html .= ' ' . "\n"; + $html .= ' ' . "\n"; + $html .= ' ' . "\n"; + $html .= ' ' . "\n"; + + foreach ($items as $item) { + $html .= sprintf( + ' ' . "\n", + htmlspecialchars($item['link']), + htmlspecialchars($item['name']), + $item['crap'], + sprintf('%.1f', $item['coverage']), + ); + } + + $html .= ' ' . "\n"; + $html .= '
%sCRAPCoverage
%s%s%s%%
'; + + return $html; + } + + 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/ClassView/Renderer/Namespace_.php b/src/Report/Html/ClassView/Renderer/Namespace_.php new file mode 100644 index 000000000..ca49b95dd --- /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->templateNameForTier('namespace'); + $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->templateNameForTier('namespace_item'); + + 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->templateNameForTier('namespace_item'); + + 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 e96bcf611..e46048ad2 100644 --- a/src/Report/Html/Facade.php +++ b/src/Report/Html/Facade.php @@ -10,6 +10,7 @@ namespace SebastianBergmann\CodeCoverage\Report\Html; use const DIRECTORY_SEPARATOR; +use function array_key_exists; use function assert; use function copy; use function date; @@ -19,6 +20,12 @@ use SebastianBergmann\CodeCoverage\Node\AbstractNode; use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode; use SebastianBergmann\CodeCoverage\Node\File as FileNode; +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; @@ -53,32 +60,26 @@ public function process(DirectoryNode $report, string $target): void $hasBranchCoverage = $report->numberOfExecutableBranches() > 0; $hasPathCoverage = $report->numberOfExecutablePaths() > 0; - $dashboard = new Dashboard( - $this->templatePath, - $this->generator, - $date, - $this->thresholds, - $hasBranchCoverage, - $hasPathCoverage, - ); + $builder = new Builder; + $rootNamespace = $builder->build($report); + $fileToClassMap = $this->buildFileToClassMap($rootNamespace); - $directory = new Directory( - $this->templatePath, - $this->generator, - $date, - $this->thresholds, - $hasBranchCoverage, - $hasPathCoverage, - ); + $this->renderFileView($report, $target, $date, $hasBranchCoverage, $hasPathCoverage, $fileToClassMap); + $this->renderClassView($rootNamespace, $target, $date, $hasBranchCoverage, $hasPathCoverage); + $this->copyFiles($target); + $this->renderCss($target); + } - $file = new File( - $this->templatePath, - $this->generator, - $date, - $this->thresholds, - $hasBranchCoverage, - $hasPathCoverage, - ); + /** + * @param array $fileToClassMap + */ + private function renderFileView(DirectoryNode $report, string $target, string $date, bool $hasBranchCoverage, bool $hasPathCoverage, array $fileToClassMap): void + { + $dashboard = new Dashboard($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage, $hasPathCoverage); + $directory = new Directory($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage, $hasPathCoverage); + $file = new File($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage, $hasPathCoverage); + + $file->setFileToClassMap($fileToClassMap); $directory->render($report, $target . 'index.html'); $dashboard->render($report, $target . 'dashboard.html'); @@ -101,9 +102,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, bool $hasPathCoverage): void + { + $classTarget = $this->directory($target . '_classes'); + + $namespaceRenderer = new NamespaceRenderer($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage, $hasPathCoverage); + $classRenderer = new ClassRenderer($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage, $hasPathCoverage); + $dashboard = new ClassDashboard($this->templatePath, $this->generator, $date, $this->thresholds, $hasBranchCoverage, $hasPathCoverage); + + $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 372242f60..763674bbc 100644 --- a/src/Report/Html/Renderer.php +++ b/src/Report/Html/Renderer.php @@ -9,6 +9,8 @@ */ namespace SebastianBergmann\CodeCoverage\Report\Html; +use const ENT_COMPAT; +use const ENT_HTML401; use const ENT_HTML5; use const ENT_QUOTES; use const ENT_SUBSTITUTE; @@ -56,9 +58,13 @@ * icon?: string, * crap?: int|string, * } + * + * @phpstan-import-type TestType from \SebastianBergmann\CodeCoverage\CodeCoverage */ abstract class Renderer { + protected const int HTML_SPECIAL_CHARS_FLAGS = ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE; + protected readonly SyntaxHighlighter $syntaxHighlighter; protected string $templatePath; protected string $generator; protected string $date; @@ -67,6 +73,11 @@ abstract class Renderer protected bool $hasPathCoverage; protected string $version; + /** + * @var array + */ + private array $fileToClassMap = []; + public function __construct(string $templatePath, string $generator, string $date, Thresholds $thresholds, bool $hasBranchCoverage, bool $hasPathCoverage) { $this->templatePath = $templatePath; @@ -76,6 +87,15 @@ public function __construct(string $templatePath, string $generator, string $dat $this->version = Version::id(); $this->hasBranchCoverage = $hasBranchCoverage; $this->hasPathCoverage = $hasPathCoverage; + $this->syntaxHighlighter = new SyntaxHighlighter; + } + + /** + * @param array $map + */ + public function setFileToClassMap(array $map): void + { + $this->fileToClassMap = $map; } /** @@ -224,11 +244,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' => $this->escapeHtml($node->pathAsString()), - 'path_to_root' => $this->pathToRoot($node), + 'path_to_root' => $pathToRoot, 'breadcrumbs' => $this->breadcrumbs($node), 'date' => $this->date, 'version' => $this->version, @@ -236,6 +263,7 @@ 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), ], ); } @@ -245,6 +273,31 @@ protected function escapeHtml(string $value): string return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5); } + 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 = ''; @@ -336,7 +389,52 @@ 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(); + } + + /** + * @param TestType $testData + */ + 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), + ); + } + + protected function runtimeString(): string { $runtime = new Runtime; diff --git a/src/Report/Html/Renderer/File.php b/src/Report/Html/Renderer/File.php index 6d68aec1e..33a5e31d2 100644 --- a/src/Report/Html/Renderer/File.php +++ b/src/Report/Html/Renderer/File.php @@ -9,9 +9,6 @@ */ namespace SebastianBergmann\CodeCoverage\Report\Html; -use const ENT_COMPAT; -use const ENT_HTML401; -use const ENT_SUBSTITUTE; use function array_key_exists; use function array_keys; use function array_merge; @@ -33,7 +30,6 @@ use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; use SebastianBergmann\CodeCoverage\Node\File as FileNode; -use SebastianBergmann\CodeCoverage\Report\Thresholds; use SebastianBergmann\CodeCoverage\Util\Percentage; use SebastianBergmann\Template\Exception; use SebastianBergmann\Template\Template; @@ -47,16 +43,6 @@ */ final class File extends Renderer { - private const int HTML_SPECIAL_CHARS_FLAGS = ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE; - private readonly SyntaxHighlighter $syntaxHighlighter; - - public function __construct(string $templatePath, string $generator, string $date, Thresholds $thresholds, bool $hasBranchCoverage, bool $hasPathCoverage) - { - parent::__construct($templatePath, $generator, $date, $thresholds, $hasBranchCoverage, $hasPathCoverage); - - $this->syntaxHighlighter = new SyntaxHighlighter; - } - public function render(FileNode $node, string $file): void { $template = new Template($this->templateNameForTier('file'), '{{', '}}'); @@ -914,20 +900,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(); - } - private function abbreviateClassName(string $className): string { $tmp = explode('\\', $className); @@ -953,35 +925,4 @@ private function abbreviateMethodName(string $methodName): string return $this->escapeHtml($methodName); } - - /** - * @param TestType $testData - */ - 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), - ); - } } 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..f16519b64 --- /dev/null +++ b/src/Report/Html/Renderer/Template/class_branch.html.dist @@ -0,0 +1,65 @@ + + + + + Code Coverage for {{full_path}} + + + + + + + +
+
+
+
+ +{{view_switcher}} +
+
+
+
+
+
+ + + + + + + + + + + + + + + +{{items}} + +
 
Code Coverage
 
Lines
Branches
Methods
Classes
+
+{{sections}} + +
+ + + + + diff --git a/src/Report/Html/Renderer/Template/class_branch_and_path.html.dist b/src/Report/Html/Renderer/Template/class_branch_and_path.html.dist new file mode 100644 index 000000000..dc39c7419 --- /dev/null +++ b/src/Report/Html/Renderer/Template/class_branch_and_path.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..089407ba5 --- /dev/null +++ b/src/Report/Html/Renderer/Template/class_item_branch.html.dist @@ -0,0 +1,17 @@ + + {{name}} + {{lines_bar}} +
{{lines_executed_percent}}
+
{{lines_number}}
+ {{branches_bar}} +
{{branches_executed_percent}}
+
{{branches_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_and_path.html.dist b/src/Report/Html/Renderer/Template/class_item_branch_and_path.html.dist new file mode 100644 index 000000000..505025179 --- /dev/null +++ b/src/Report/Html/Renderer/Template/class_item_branch_and_path.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 a2a2b7bed..06e8dbe38 100644 --- a/src/Report/Html/Renderer/Template/dashboard.html.dist +++ b/src/Report/Html/Renderer/Template/dashboard.html.dist @@ -18,6 +18,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 a2a2b7bed..06e8dbe38 100644 --- a/src/Report/Html/Renderer/Template/dashboard_branch.html.dist +++ b/src/Report/Html/Renderer/Template/dashboard_branch.html.dist @@ -18,6 +18,7 @@ {{breadcrumbs}} +{{view_switcher}} diff --git a/src/Report/Html/Renderer/Template/dashboard_branch_and_path.html.dist b/src/Report/Html/Renderer/Template/dashboard_branch_and_path.html.dist index a2a2b7bed..06e8dbe38 100644 --- a/src/Report/Html/Renderer/Template/dashboard_branch_and_path.html.dist +++ b/src/Report/Html/Renderer/Template/dashboard_branch_and_path.html.dist @@ -18,6 +18,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 07ef7a33b..898692276 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/directory_branch_and_path.html.dist b/src/Report/Html/Renderer/Template/directory_branch_and_path.html.dist index a40c2e128..45681fcfb 100644 --- a/src/Report/Html/Renderer/Template/directory_branch_and_path.html.dist +++ b/src/Report/Html/Renderer/Template/directory_branch_and_path.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 46be3117f..280a205ff 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/file_branch_and_path.html.dist b/src/Report/Html/Renderer/Template/file_branch_and_path.html.dist index b8bcf3747..6c6b83212 100644 --- a/src/Report/Html/Renderer/Template/file_branch_and_path.html.dist +++ b/src/Report/Html/Renderer/Template/file_branch_and_path.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..d23c23559 --- /dev/null +++ b/src/Report/Html/Renderer/Template/namespace_branch.html.dist @@ -0,0 +1,62 @@ + + + + + Code Coverage for {{full_path}} + + + + + + + +
+
+
+
+ +{{view_switcher}} +
+
+
+
+
+
+ + + + + + + + + + + + + + + +{{items}} + +
 
Code Coverage
 
Lines
Branches
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_and_path.html.dist b/src/Report/Html/Renderer/Template/namespace_branch_and_path.html.dist new file mode 100644 index 000000000..f13deeca5 --- /dev/null +++ b/src/Report/Html/Renderer/Template/namespace_branch_and_path.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..1d1884ff3 --- /dev/null +++ b/src/Report/Html/Renderer/Template/namespace_item_branch.html.dist @@ -0,0 +1,16 @@ + + {{icon}}{{name}} + {{lines_bar}} +
{{lines_executed_percent}}
+
{{lines_number}}
+ {{branches_bar}} +
{{branches_executed_percent}}
+
{{branches_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_and_path.html.dist b/src/Report/Html/Renderer/Template/namespace_item_branch_and_path.html.dist new file mode 100644 index 000000000..532a436c2 --- /dev/null +++ b/src/Report/Html/Renderer/Template/namespace_item_branch_and_path.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 @@ + - Code Coverage for %sBankAccount.php + Code Coverage for %s%eBankAccount.php @@ -21,6 +21,11 @@ + + @@ -191,61 +196,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1<?php
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. -

- - - -
- - - - +%a 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..79a03a9c1 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/BankAccount.html @@ -0,0 +1,172 @@ + + + + + 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
+
+%a + + 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..29258e078 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/dashboard.html @@ -0,0 +1,173 @@ + + + + + Dashboard for (Global) + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ + + + 0% + + 20% + + 40% + + 60% + + 80% + + 100% + + 0 + + 2 + + 4 + + 6 + + + Line Coverage (%) + Cyclomatic Complexity + BankAccount — Coverage: 62.5% | Lines: 8 | Complexity: 5 + +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + + +
ClassCRAPCoverage
BankAccount6.3262.5%
+
+
+
+
+
+

Methods

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ + + + 0% + + 20% + + 40% + + 60% + + 80% + + 100% + + 0 + + 1 + + 2 + + 3 + + + Line Coverage (%) + Cyclomatic Complexity + BankAccount::setBalance — Coverage: 0.0% | Lines: 3 | Complexity: 2 + BankAccount::depositMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 + BankAccount::withdrawMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 + BankAccount::getBalance — Coverage: 100.0% | Lines: 1 | Complexity: 1 + +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + + + + + +
MethodCRAPCoverage
BankAccount::setBalance60.0%
BankAccount::getBalance1100.0%
BankAccount::depositMoney1100.0%
BankAccount::withdrawMoney1100.0%
+
+
+
+ +
+ + 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..93ff9280a --- /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 0981c2568..692cb664a 100644 --- a/tests/_files/Report/HTML/CoverageForBankAccount/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForBankAccount/dashboard.html @@ -20,6 +20,11 @@ + +
diff --git a/tests/_files/Report/HTML/CoverageForBankAccount/index.html b/tests/_files/Report/HTML/CoverageForBankAccount/index.html index 1112add13..d857ff66a 100644 --- a/tests/_files/Report/HTML/CoverageForBankAccount/index.html +++ b/tests/_files/Report/HTML/CoverageForBankAccount/index.html @@ -21,6 +21,11 @@ + + @@ -110,7 +115,7 @@

Legend

High: 90% to 100%

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

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..4105a0722 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/CoveredClassWithAnonymousFunctionInStaticMethod.html @@ -0,0 +1,106 @@ + + + + + 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
+
+%a + + 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..edf4041d5 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html @@ -0,0 +1,163 @@ + + + + + Dashboard for (Global) + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ + + + 0% + + 20% + + 40% + + 60% + + 80% + + 100% + + 0 + + 1 + + 2 + + + Line Coverage (%) + Cyclomatic Complexity + CoveredClassWithAnonymousFunctionInStaticMethod — Coverage: 100.0% | Lines: 8 | Complexity: 1 + +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + + +
ClassCRAPCoverage
CoveredClassWithAnonymousFunctionInStaticMethod1100.0%
+
+
+
+
+
+

Methods

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ + + + 0% + + 20% + + 40% + + 60% + + 80% + + 100% + + 0 + + 1 + + 2 + + + Line Coverage (%) + Cyclomatic Complexity + CoveredClassWithAnonymousFunctionInStaticMethod::runAnonymous — Coverage: 100.0% | Lines: 8 | Complexity: 1 + +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + + +
MethodCRAPCoverage
CoveredClassWithAnonymousFunctionInStaticMethod::runAnonymous1100.0%
+
+
+
+ +
+ + 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..98af65068 --- /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 7e73d8d47..b72700031 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/dashboard.html @@ -20,6 +20,11 @@ + +
diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html index ff7813e53..25517115b 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html @@ -21,6 +21,11 @@ + + @@ -110,7 +115,7 @@

Legend

High: 90% to 100%

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

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..b04ec46c2 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 @@ -2,7 +2,7 @@ - Code Coverage for %ssource_with_class_and_anonymous_function.php + Code Coverage for %s%esource_with_class_and_anonymous_function.php @@ -21,6 +21,11 @@ + + @@ -125,46 +130,6 @@ - - - - - - - - - - - - - - - - - - - - - - - -
1<?php
2
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. -

- - - -
- - - - +%a 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..ccf781e37 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Bar.html @@ -0,0 +1,81 @@ + + + + + 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
+
+%a + + 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..0a56a6027 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Foo.html @@ -0,0 +1,81 @@ + + + + + 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
+
+%a + + 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..4d48a0299 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/dashboard.html @@ -0,0 +1,97 @@ + + + + + Dashboard for (Global) + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + + + +
ClassCRAPCoverage
Foo1100.0%
Bar1100.0%
+
+
+
+
+
+

Methods

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + + + +
MethodCRAPCoverage
Foo::bar1100.0%
Bar::foo1100.0%
+
+
+
+ +
+ + 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..35540b05b --- /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 46d1348d3..413621e93 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/dashboard.html @@ -20,6 +20,11 @@ + +
diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html index 73c808041..6e37bcc36 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html @@ -21,6 +21,11 @@ + + @@ -100,7 +105,7 @@

Legend

High: 90% to 100%

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

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..0aff4cfb3 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html @@ -2,7 +2,7 @@ - Code Coverage for %ssource_with_ignore.php + Code Coverage for %s%esource_with_ignore.php @@ -21,6 +21,11 @@ + + @@ -133,69 +138,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1<?php
2if ($neverHappens) {
3    // @codeCoverageIgnoreStart
4    print '*';
5    // @codeCoverageIgnoreEnd
6}
7
8/**
9 * @codeCoverageIgnore
10 */
11class Foo
12{
13    public function bar()
14    {
15    }
16}
17
18class Bar
19{
20    /**
21     * @codeCoverageIgnore
22     */
23    public function foo()
24    {
25    }
26}
27
28function baz()
29{
30    print '*'; // @codeCoverageIgnore
31}
32
33interface Bor
34{
35    public function foo();
36}
37
38// @codeCoverageIgnoreStart
39print '
40Multiline
41';
42// @codeCoverageIgnoreEnd
- - -
-
-

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. -

- - - -
- - - - +%a diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html index 2a9a95a0f..4e7df7dc6 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html @@ -2,7 +2,7 @@ - Code Coverage for %sBankAccount.php + Code Coverage for %s%eBankAccount.php @@ -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 d81644fed..cc41f8356 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html @@ -2,7 +2,7 @@ - Code Coverage for %sBankAccount.php + Code Coverage for %s%eBankAccount.php @@ -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 d81644fed..cc41f8356 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html @@ -2,7 +2,7 @@ - Code Coverage for %sBankAccount.php + Code Coverage for %s%eBankAccount.php @@ -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..6dd1b1c8b --- /dev/null +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/BankAccount.html @@ -0,0 +1,254 @@ + + + + + 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
+
+%a + + 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..aec164df0 --- /dev/null +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html @@ -0,0 +1,173 @@ + + + + + Dashboard for (Global) + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ + + + 0% + + 20% + + 40% + + 60% + + 80% + + 100% + + 0 + + 2 + + 4 + + 6 + + + Line Coverage (%) + Cyclomatic Complexity + BankAccount — Coverage: 42.9% | Lines: 8 | Complexity: 5 + +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + + +
ClassCRAPCoverage
BankAccount9.6642.9%
+
+
+
+
+
+

Methods

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ + + + 0% + + 20% + + 40% + + 60% + + 80% + + 100% + + 0 + + 1 + + 2 + + 3 + + + Line Coverage (%) + Cyclomatic Complexity + BankAccount::setBalance — Coverage: 0.0% | Lines: 3 | Complexity: 2 + BankAccount::depositMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 + BankAccount::withdrawMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 + BankAccount::getBalance — Coverage: 100.0% | Lines: 1 | Complexity: 1 + +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + + + + + +
MethodCRAPCoverage
BankAccount::setBalance60.0%
BankAccount::getBalance1100.0%
BankAccount::depositMoney1100.0%
BankAccount::withdrawMoney1100.0%
+
+
+
+ +
+ + 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..49bd4e8b8 --- /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 8e0d9483e..d414a6726 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/dashboard.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/dashboard.html @@ -20,6 +20,11 @@ + +
diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html index 0947cd929..75443813e 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html @@ -21,6 +21,11 @@ + + @@ -144,7 +149,7 @@

Legend

High: 90% to 100%

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

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..b6f4fad7f --- /dev/null +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html @@ -0,0 +1,77 @@ + + + + + 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
+
+%a + + 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..98d56ee35 --- /dev/null +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html @@ -0,0 +1,94 @@ + + + + + Dashboard for (Global) + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + + +
ClassCRAPCoverage
Foo0100.0%
+
+
+
+
+
+

Methods

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + +
MethodCRAPCoverage
+
+
+
+ +
+ + 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..876aa4dd6 --- /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 f11a8d229..d52ae254a 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/dashboard.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/dashboard.html @@ -20,6 +20,11 @@ + +
diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html index 51d43fe05..000d4423b 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html @@ -21,6 +21,11 @@ + + @@ -134,7 +139,7 @@

Legend

High: 90% to 100%

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

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..90c010e82 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html @@ -2,7 +2,7 @@ - Code Coverage for %ssource_without_namespace.php + Code Coverage for %s%esource_without_namespace.php @@ -21,6 +21,11 @@ + + @@ -145,45 +150,6 @@ - - - - - - - - - - - - - - - - - - - - - - -
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
- - -
-
-

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. -

- - - -
- - - - +%a 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..90c010e82 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 @@ -2,7 +2,7 @@ - Code Coverage for %ssource_without_namespace.php + Code Coverage for %s%esource_without_namespace.php @@ -21,6 +21,11 @@ + + @@ -145,91 +150,6 @@ - - - - - - - - - - - - - - - - - - - - - - -
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
- -
-

Branches

-

- Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not - necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. - Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement - always has an else as part of its logical flow even if you didn't write one. -

-
foo
- - - - - - - - -
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
- - - - - -
15    $a   = true ? true : false;
- - - - - -
15    $a   = true ? true : false;
- - - - - - - -
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
-
{closure:%ssource_without_namespace.php:14-14}
- - - - - -
14    $baz = function () {};
- - - - - - - +%a 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..90c010e82 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 @@ -2,7 +2,7 @@ - Code Coverage for %ssource_without_namespace.php + Code Coverage for %s%esource_without_namespace.php @@ -21,6 +21,11 @@ + + @@ -145,92 +150,6 @@ - - - - - - - - - - - - - - - - - - - - - - -
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
- -
-

Paths

-

- Below are the source code lines that represent each code path as identified by Xdebug. Please note a path is not - necessarily coterminous with a line, a line may contain multiple paths and therefore show up more than once. - Please also be aware that some paths may include implicit rather than explicit branches, e.g. an if statement - always has an else as part of its logical flow even if you didn't write one. -

-
foo
- - - - - - - - - - - - - - -
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
 
15    $a   = true ? true : false;
 
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
- - - - - - - - - - - - - - -
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
 
15    $a   = true ? true : false;
 
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
-
{closure:%ssource_without_namespace.php:14-14}
- - - - - -
14    $baz = function () {};
- - - - - - - +%a diff --git a/tests/tests/Node/FileTest.php b/tests/tests/Node/FileTest.php index ba8f63e4b..6f1fb193b 100644 --- a/tests/tests/Node/FileTest.php +++ b/tests/tests/Node/FileTest.php @@ -397,6 +397,54 @@ public function testCountsTestedMethods(): void $this->assertSame(2, $file->numberOfTestedMethods()); } + public function testNumberOfTestedMethodsIncludesFullyCoveredTraitMethods(): void + { + $root = new Directory('root'); + + $method = new Method( + 'traitMethod', + 1, + 5, + 'public function traitMethod(): void', + Visibility::Public, + 1, + ); + + $trait = new Trait_( + 'MyTrait', + 'MyTrait', + '', + 'test.php', + 1, + 5, + [], + ['traitMethod' => $method], + ); + + $lineCoverageData = [ + 1 => ['test1'], + 2 => ['test1'], + 3 => ['test1'], + 4 => ['test1'], + 5 => ['test1'], + ]; + + $file = new File( + 'test.php', + $root, + 'abc123', + $lineCoverageData, + [], + ['test1' => ['size' => 'small', 'status' => 'passed', 'time' => 0.0]], + [], + ['MyTrait' => $trait], + [], + new LinesOfCode(5, 0, 5), + ); + + $this->assertSame(1, $file->numberOfTestedMethods()); + } + private function createFileNodeWithTestedClassAndTrait(): File { $root = new Directory('root'); diff --git a/tests/tests/Report/Html/ClassView/BuilderTest.php b/tests/tests/Report/Html/ClassView/BuilderTest.php new file mode 100644 index 000000000..6f740111f --- /dev/null +++ b/tests/tests/Report/Html/ClassView/BuilderTest.php @@ -0,0 +1,311 @@ + + * + * 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()); + } + + /** + * @param non-empty-string $className + */ + 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..a44b48bfc --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Node/ClassNodeTest.php @@ -0,0 +1,381 @@ + + * + * 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); + } + + /** + * @param non-empty-string $className + */ + 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..a20a1f50c --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Node/NamespaceNodeTest.php @@ -0,0 +1,357 @@ + + * + * 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 + * @param non-empty-string $className + */ + 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/ClassView/Renderer/Class_Test.php b/tests/tests/Report/Html/ClassView/Renderer/Class_Test.php new file mode 100644 index 000000000..55dd2d654 --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Renderer/Class_Test.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 const DIRECTORY_SEPARATOR; +use function file_get_contents; +use function is_file; +use function sys_get_temp_dir; +use function tempnam; +use function unlink; +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\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\Report\Thresholds; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; + +#[CoversClass(Class_::class)] +#[Small] +final class Class_Test extends TestCase +{ + private string $outputFile = ''; + + protected function tearDown(): void + { + if ($this->outputFile !== '' && is_file($this->outputFile)) { + unlink($this->outputFile); + } + } + + public function testRendersClassWithTraitAndParentSectionsInNestedNamespace(): void + { + $renderer = $this->createRenderer(false); + $node = $this->createClassNodeWithTraitAndParent(); + + $renderer->render($node, $this->outputFile()); + + $output = file_get_contents($this->outputFile); + + $this->assertNotFalse($output); + + $this->assertStringContainsString('App\\Models\\ChildClass', $output); + $this->assertStringContainsString('From Example\\ExampleTrait', $output); + $this->assertStringContainsString('Inherited from Example\\ParentClass', $output); + $this->assertStringContainsString('[Example\\ExampleTrait]', $output); + $this->assertStringContainsString('[Example\\ParentClass]', $output); + // pathToRoot uses one extra level for nested namespace + _classes + $this->assertStringContainsString('../../../_css/', $output); + } + + public function testRendersClassWithBranchCoverageTemplate(): void + { + $renderer = $this->createRenderer(true); + $node = $this->createClassNodeWithTraitAndParent(); + + $renderer->render($node, $this->outputFile()); + + $output = file_get_contents($this->outputFile); + + $this->assertNotFalse($output); + + $this->assertStringContainsString('App\\Models\\ChildClass', $output); + } + + public function testMarksLinesCoveredBySmallAndMediumTests(): void + { + $renderer = $this->createRenderer(false); + + $rootDir = new Directory('root'); + $method = new ProcessedMethodType( + 'doSomething', + 'public', + 'public function doSomething(): void', + 7, + 10, + 2, + 2, + 0, + 0, + 0, + 0, + 1, + 100, + 1, + '', + ); + $processedClass = new ProcessedClassType( + 'Example\\ExampleClass', + 'Example', + ['doSomething' => $method], + 5, + 2, + 2, + 0, + 0, + 0, + 0, + 1, + 100, + 1, + '', + ); + + $filePath = TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ExampleClass.php'; + $lineCoverageData = [ + 7 => ['SmallTest'], + 9 => ['MediumTest'], + ]; + $testData = [ + 'SmallTest' => ['size' => 'small', 'status' => 'passed', 'time' => 0.0], + 'MediumTest' => ['size' => 'medium', 'status' => 'passed', 'time' => 0.0], + ]; + + $fileNode = new File( + 'ExampleClass.php', + $rootDir, + 'abc', + $lineCoverageData, + [], + $testData, + [], + [], + [], + new LinesOfCode(16, 0, 16), + ); + + $parent = new NamespaceNode('Example', 'Example'); + $parent->promoteToRoot(); + + $classNode = new ClassNode( + 'Example\\ExampleClass', + 'Example', + $filePath, + 5, + 16, + $processedClass, + $fileNode, + [], + [], + $parent, + ); + + $renderer->render($classNode, $this->outputFile()); + + $output = file_get_contents($this->outputFile); + + $this->assertNotFalse($output); + + $this->assertStringContainsString('covered-by-small-tests', $output); + $this->assertStringContainsString('covered-by-medium-tests', $output); + } + + public function testSkipsLinesOutsideOfSourceFile(): void + { + $renderer = $this->createRenderer(false); + + $rootDir = new Directory('root'); + $method = new ProcessedMethodType( + 'doSomething', + 'public', + 'public function doSomething(): void', + 7, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 1, + '', + ); + $processedClass = new ProcessedClassType( + 'Example\\ExampleClass', + 'Example', + ['doSomething' => $method], + 5, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 1, + '', + ); + + $filePath = TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ExampleClass.php'; + $fileNode = new File( + 'ExampleClass.php', + $rootDir, + 'abc', + [], + [], + [], + [], + [], + [], + new LinesOfCode(16, 0, 16), + ); + + $parent = new NamespaceNode('Example', 'Example'); + $parent->promoteToRoot(); + + // endLine is far past the actual end of the file, forcing the `!isset($codeLines[$lineIndex])` branch + $classNode = new ClassNode( + 'Example\\ExampleClass', + 'Example', + $filePath, + 1, + 9999, + $processedClass, + $fileNode, + [], + [], + $parent, + ); + + $renderer->render($classNode, $this->outputFile()); + + $this->assertFileExists($this->outputFile); + } + + private function createRenderer(bool $hasBranchCoverage): Class_ + { + return new Class_( + __DIR__ . '/../../../../../../src/Report/Html/Renderer/Template/', + 'test-generator', + 'Jan 1 00:00:00 UTC 2026', + Thresholds::default(), + $hasBranchCoverage, + false, + ); + } + + private function outputFile(): string + { + $this->outputFile = tempnam(sys_get_temp_dir(), 'cov_'); + + return $this->outputFile; + } + + private function createClassNodeWithTraitAndParent(): ClassNode + { + $root = new Directory('root'); + + $classMethod = new ProcessedMethodType( + 'childMethod', + 'public', + 'public function childMethod(): void', + 9, + 12, + 1, + 1, + 0, + 0, + 0, + 0, + 1, + 100, + 1, + '', + ); + + $traitMethod = new ProcessedMethodType( + 'traitMethod', + 'public', + 'public function traitMethod(): string', + 7, + 10, + 1, + 1, + 0, + 0, + 0, + 0, + 1, + 100, + 1, + '', + ); + + $parentMethod = new ProcessedMethodType( + 'parentMethod', + 'public', + 'public function parentMethod(): void', + 7, + 10, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 1, + '', + ); + + $processedClass = new ProcessedClassType( + 'App\\Models\\ChildClass', + 'App\\Models', + ['childMethod' => $classMethod], + 5, + 1, + 1, + 0, + 0, + 0, + 0, + 1, + 100, + 1, + '', + ); + + $processedTrait = new ProcessedTraitType( + 'Example\\ExampleTrait', + 'Example', + ['traitMethod' => $traitMethod], + 5, + 1, + 1, + 0, + 0, + 0, + 0, + 1, + 100, + 1, + '', + ); + + $classFilePath = TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ChildClass.php'; + $traitFilePath = TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ExampleTrait.php'; + $parentFilePath = TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ParentClass.php'; + + $classFileNode = new File('ChildClass.php', $root, 'a', [], [], [], [], [], [], new LinesOfCode(13, 0, 13)); + $traitFileNode = new File('ExampleTrait.php', $root, 'b', [], [], [], [], [], [], new LinesOfCode(11, 0, 11)); + $parentFileNode = new File('ParentClass.php', $root, 'c', [], [], [], [], [], [], new LinesOfCode(11, 0, 11)); + + $traitSection = new TraitSection('Example\\ExampleTrait', $traitFilePath, 5, 11, $processedTrait, $traitFileNode); + $parentSection = new ParentSection('Example\\ParentClass', $parentFilePath, ['parentMethod' => $parentMethod], $parentFileNode); + + // Build nested namespace: index (root) -> App -> Models + $rootNs = new NamespaceNode('(Global)', ''); + $rootNs->promoteToRoot(); + $appNs = new NamespaceNode('App', 'App', $rootNs); + $modelsNs = new NamespaceNode('Models', 'App\\Models', $appNs); + $rootNs->addNamespace($appNs); + $appNs->addNamespace($modelsNs); + + return new ClassNode( + 'App\\Models\\ChildClass', + 'App\\Models', + $classFilePath, + 5, + 13, + $processedClass, + $classFileNode, + [$traitSection], + [$parentSection], + $modelsNs, + ); + } +} diff --git a/tests/tests/Report/Html/ClassView/Renderer/DashboardTest.php b/tests/tests/Report/Html/ClassView/Renderer/DashboardTest.php new file mode 100644 index 000000000..a40d203b9 --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Renderer/DashboardTest.php @@ -0,0 +1,163 @@ + + * + * 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 file_get_contents; +use function is_file; +use function sys_get_temp_dir; +use function tempnam; +use function unlink; +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\Report\Html\ClassView\Node\ClassNode; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\NamespaceNode; +use SebastianBergmann\CodeCoverage\Report\Thresholds; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; + +#[CoversClass(Dashboard::class)] +#[Small] +final class DashboardTest extends TestCase +{ + private string $outputFile = ''; + + protected function tearDown(): void + { + if ($this->outputFile !== '' && is_file($this->outputFile)) { + unlink($this->outputFile); + } + } + + public function testRendersRootDashboard(): void + { + $renderer = $this->createRenderer(false); + + $rootNs = new NamespaceNode('(Global)', ''); + $rootNs->promoteToRoot(); + $rootNs->addClass($this->createClassNode($rootNs, 'App\\Root')); + + $renderer->render($rootNs, $this->outputFile()); + + $output = file_get_contents($this->outputFile); + + $this->assertNotFalse($output); + + $this->assertStringContainsString('App\\Root', $output); + } + + public function testRendersNestedNamespaceBreadcrumbs(): void + { + $renderer = $this->createRenderer(false); + + $rootNs = new NamespaceNode('(Global)', ''); + $rootNs->promoteToRoot(); + $appNs = new NamespaceNode('App', 'App', $rootNs); + $modelsNs = new NamespaceNode('Models', 'App\\Models', $appNs); + $rootNs->addNamespace($appNs); + $appNs->addNamespace($modelsNs); + + $modelsNs->addClass($this->createClassNode($modelsNs, 'App\\Models\\User')); + + $renderer->render($modelsNs, $this->outputFile()); + + $output = file_get_contents($this->outputFile); + + $this->assertNotFalse($output); + + // Inactive breadcrumbs for ancestors + $this->assertStringContainsString('', $output); + $this->assertStringContainsString('', $output); + // Active-ish breadcrumb for current node (dashboard uses index.html link) + $this->assertStringContainsString('', $output); + $this->assertStringContainsString('(Dashboard)', $output); + // pathToRoot for nested namespace + _classes + $this->assertStringContainsString('../../../_css/', $output); + } + + public function testRendersWithBranchCoverage(): void + { + $renderer = $this->createRenderer(true); + + $rootNs = new NamespaceNode('(Global)', ''); + $rootNs->promoteToRoot(); + $rootNs->addClass($this->createClassNode($rootNs, 'Root')); + + $renderer->render($rootNs, $this->outputFile()); + + $this->assertFileExists($this->outputFile); + } + + private function createRenderer(bool $hasBranchCoverage): Dashboard + { + return new Dashboard( + __DIR__ . '/../../../../../../src/Report/Html/Renderer/Template/', + 'test-generator', + 'Jan 1 00:00:00 UTC 2026', + Thresholds::default(), + $hasBranchCoverage, + false, + ); + } + + private function outputFile(): string + { + $this->outputFile = tempnam(sys_get_temp_dir(), 'cov_'); + + return $this->outputFile; + } + + /** + * @param non-empty-string $className + */ + private function createClassNode(NamespaceNode $parent, string $className): ClassNode + { + $root = new Directory('root'); + $method = new ProcessedMethodType( + 'doSomething', + 'public', + 'public function doSomething(): void', + 1, + 5, + 3, + 3, + 0, + 0, + 0, + 0, + 1, + 100, + 1, + '', + ); + $processedClass = new ProcessedClassType( + $className, + '', + ['doSomething' => $method], + 1, + 3, + 3, + 0, + 0, + 0, + 0, + 1, + 100, + 1, + '', + ); + $fileNode = new File('t.php', $root, 'a', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + return new ClassNode($className, '', '/t.php', 1, 10, $processedClass, $fileNode, [], [], $parent); + } +} diff --git a/tests/tests/Report/Html/ClassView/Renderer/Namespace_Test.php b/tests/tests/Report/Html/ClassView/Renderer/Namespace_Test.php new file mode 100644 index 000000000..19ba8d7e5 --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Renderer/Namespace_Test.php @@ -0,0 +1,167 @@ + + * + * 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 file_get_contents; +use function is_file; +use function sys_get_temp_dir; +use function tempnam; +use function unlink; +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\Report\Html\ClassView\Node\ClassNode; +use SebastianBergmann\CodeCoverage\Report\Html\ClassView\Node\NamespaceNode; +use SebastianBergmann\CodeCoverage\Report\Thresholds; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; + +#[CoversClass(Namespace_::class)] +#[Small] +final class Namespace_Test extends TestCase +{ + private string $outputFile = ''; + + protected function tearDown(): void + { + if ($this->outputFile !== '' && is_file($this->outputFile)) { + unlink($this->outputFile); + } + } + + public function testRendersRootNamespaceWithChildAndClass(): void + { + $renderer = $this->createRenderer(false); + + $rootNs = new NamespaceNode('(Global)', ''); + $rootNs->promoteToRoot(); + $appNs = new NamespaceNode('App', 'App', $rootNs); + $rootNs->addNamespace($appNs); + + $rootNs->addClass($this->createClassNode($rootNs, 'App\\Root')); + + $renderer->render($rootNs, $this->outputFile()); + + $output = file_get_contents($this->outputFile); + + $this->assertNotFalse($output); + + $this->assertStringContainsString('Total', $output); + $this->assertStringContainsString('App', $output); + $this->assertStringContainsString('Root', $output); + } + + public function testRendersNestedNamespaceBreadcrumbs(): void + { + $renderer = $this->createRenderer(false); + + $rootNs = new NamespaceNode('(Global)', ''); + $rootNs->promoteToRoot(); + $appNs = new NamespaceNode('App', 'App', $rootNs); + $modelsNs = new NamespaceNode('Models', 'App\\Models', $appNs); + $rootNs->addNamespace($appNs); + $appNs->addNamespace($modelsNs); + + $modelsNs->addClass($this->createClassNode($modelsNs, 'App\\Models\\User')); + + $renderer->render($modelsNs, $this->outputFile()); + + $output = file_get_contents($this->outputFile); + + $this->assertNotFalse($output); + + // Inactive breadcrumbs for ancestors + $this->assertStringContainsString('', $output); + $this->assertStringContainsString('', $output); + // Active breadcrumb for current node + $this->assertStringContainsString('', $output); + // pathToRoot uses parent depth + _classes level + $this->assertStringContainsString('../../../_css/', $output); + } + + public function testRendersWithBranchCoverage(): void + { + $renderer = $this->createRenderer(true); + + $rootNs = new NamespaceNode('(Global)', ''); + $rootNs->promoteToRoot(); + $rootNs->addClass($this->createClassNode($rootNs, 'Root')); + + $renderer->render($rootNs, $this->outputFile()); + + $this->assertFileExists($this->outputFile); + } + + private function createRenderer(bool $hasBranchCoverage): Namespace_ + { + return new Namespace_( + __DIR__ . '/../../../../../../src/Report/Html/Renderer/Template/', + 'test-generator', + 'Jan 1 00:00:00 UTC 2026', + Thresholds::default(), + $hasBranchCoverage, + false, + ); + } + + private function outputFile(): string + { + $this->outputFile = tempnam(sys_get_temp_dir(), 'cov_'); + + return $this->outputFile; + } + + /** + * @param non-empty-string $className + */ + private function createClassNode(NamespaceNode $parent, string $className): ClassNode + { + $root = new Directory('root'); + $method = new ProcessedMethodType( + 'doSomething', + 'public', + 'public function doSomething(): void', + 1, + 5, + 3, + 3, + 0, + 0, + 0, + 0, + 1, + 100, + 1, + '', + ); + $processedClass = new ProcessedClassType( + $className, + '', + ['doSomething' => $method], + 1, + 3, + 3, + 0, + 0, + 0, + 0, + 1, + 100, + 1, + '', + ); + $fileNode = new File('t.php', $root, 'a', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + + return new ClassNode($className, '', '/t.php', 1, 10, $processedClass, $fileNode, [], [], $parent); + } +} diff --git a/tests/tests/Report/Html/EndToEndTest.php b/tests/tests/Report/Html/EndToEndTest.php index 86a889a44..0defd33c2 100644 --- a/tests/tests/Report/Html/EndToEndTest.php +++ b/tests/tests/Report/Html/EndToEndTest.php @@ -13,6 +13,7 @@ use const PHP_EOL; use function file_get_contents; use function file_put_contents; +use function is_dir; use function iterator_count; use function mkdir; use function str_replace; @@ -159,8 +160,21 @@ public function testHtmlSpecialCharactersInFileAndDirectoryNamesAreEncoded(): vo 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->assertSame( iterator_count($expectedFilesIterator), diff --git a/tests/tests/Report/Html/FacadeTest.php b/tests/tests/Report/Html/FacadeTest.php new file mode 100644 index 000000000..608579206 --- /dev/null +++ b/tests/tests/Report/Html/FacadeTest.php @@ -0,0 +1,193 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html; + +use const DIRECTORY_SEPARATOR; +use function file_get_contents; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Medium; +use SebastianBergmann\CodeCoverage\Node\Directory as DirectoryNode; +use SebastianBergmann\CodeCoverage\Node\File as FileNode; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Class_; +use SebastianBergmann\CodeCoverage\StaticAnalysis\LinesOfCode; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Method; +use SebastianBergmann\CodeCoverage\StaticAnalysis\Visibility; +use SebastianBergmann\CodeCoverage\TestCase; + +#[CoversClass(Facade::class)] +#[Medium] +final class FacadeTest extends TestCase +{ + protected function tearDown(): void + { + $this->removeTemporaryFiles(); + } + + public function testProcessRendersFilesInSubdirectoriesAndClassesInNestedNamespaces(): void + { + $target = TEST_FILES_PATH . 'tmp' . DIRECTORY_SEPARATOR; + $report = $this->buildReportWithNestedNamespacesAndSubdirectory(); + + (new Facade)->process($report, $target); + + // renderFileView: subdirectory iteration (Facade.php lines 86-90) + $this->assertFileExists($target . 'sub/index.html'); + $this->assertFileExists($target . 'sub/dashboard.html'); + $this->assertFileExists($target . 'sub/User.php.html'); + $this->assertFileExists($target . 'sub/HomeController.php.html'); + $this->assertFileExists($target . 'GlobalClass.php.html'); + + // renderClassView: nested namespace NamespaceNode iteration (lines 114-119) + $this->assertFileExists($target . '_classes/App/index.html'); + $this->assertFileExists($target . '_classes/App/dashboard.html'); + $this->assertFileExists($target . '_classes/App/Models/index.html'); + $this->assertFileExists($target . '_classes/App/Controllers/index.html'); + + // renderClassView: class page in nested namespace directory (lines 126-127) + $this->assertFileExists($target . '_classes/App/Models/User.html'); + $this->assertFileExists($target . '_classes/App/Controllers/HomeController.html'); + + // Global-namespace class page sits directly under _classes + $this->assertFileExists($target . '_classes/GlobalClass.html'); + + // buildFileToClassMap: nested-namespace class path (line 158) + $fileHtml = file_get_contents($target . 'sub/User.php.html'); + + $this->assertNotFalse($fileHtml); + $this->assertStringContainsString('_classes/App/Models/User.html', $fileHtml); + } + + private function buildReportWithNestedNamespacesAndSubdirectory(): DirectoryNode + { + $rootPath = TEST_FILES_PATH . 'FacadeNested'; + $root = new DirectoryNode($rootPath); + $subDir = $root->addDirectory('sub'); + + $root->addFile($this->createGlobalClassFile($root)); + $subDir->addFile($this->createUserFile($subDir)); + $subDir->addFile($this->createHomeControllerFile($subDir)); + + return $root; + } + + private function createGlobalClassFile(DirectoryNode $parent): FileNode + { + $method = new Method( + 'doSomething', + 5, + 7, + 'public function doSomething(): void', + Visibility::Public, + 1, + ); + + $rawClass = new Class_( + 'GlobalClass', + 'GlobalClass', + '', + TEST_FILES_PATH . 'FacadeNested/GlobalClass.php', + 3, + 8, + null, + [], + [], + ['doSomething' => $method], + ); + + return new FileNode( + 'GlobalClass.php', + $parent, + 'a1', + [], + [], + [], + ['GlobalClass' => $rawClass], + [], + [], + new LinesOfCode(9, 0, 9), + ); + } + + private function createUserFile(DirectoryNode $parent): FileNode + { + $method = new Method( + 'save', + 7, + 9, + 'public function save(): void', + Visibility::Public, + 1, + ); + + $rawClass = new Class_( + 'User', + 'App\\Models\\User', + 'App\\Models', + TEST_FILES_PATH . 'FacadeNested/sub/User.php', + 5, + 10, + null, + [], + [], + ['save' => $method], + ); + + return new FileNode( + 'User.php', + $parent, + 'b2', + [], + [], + [], + ['App\\Models\\User' => $rawClass], + [], + [], + new LinesOfCode(11, 0, 11), + ); + } + + private function createHomeControllerFile(DirectoryNode $parent): FileNode + { + $method = new Method( + 'index', + 7, + 9, + 'public function index(): void', + Visibility::Public, + 1, + ); + + $rawClass = new Class_( + 'HomeController', + 'App\\Controllers\\HomeController', + 'App\\Controllers', + TEST_FILES_PATH . 'FacadeNested/sub/HomeController.php', + 5, + 10, + null, + [], + [], + ['index' => $method], + ); + + return new FileNode( + 'HomeController.php', + $parent, + 'c3', + [], + [], + [], + ['App\\Controllers\\HomeController' => $rawClass], + [], + [], + new LinesOfCode(11, 0, 11), + ); + } +} From 6e3a60de7ead54a2e0eaac91376f01c72a7962ea Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 9 Jul 2026 09:48:19 +0200 Subject: [PATCH 02/12] Fix broken links to file-view pages on class-oriented dashboard pages --- .../Html/ClassView/Renderer/Dashboard.php | 27 +++++++++---------- .../_classes/dashboard.html | 20 +++++++------- .../_classes/dashboard.html | 8 +++--- .../_classes/dashboard.html | 8 +++--- .../_classes/dashboard.html | 20 +++++++------- .../_classes/dashboard.html | 2 +- .../Html/ClassView/Renderer/DashboardTest.php | 11 ++++++-- 7 files changed, 51 insertions(+), 45 deletions(-) diff --git a/src/Report/Html/ClassView/Renderer/Dashboard.php b/src/Report/Html/ClassView/Renderer/Dashboard.php index 452d91190..db32c6e96 100644 --- a/src/Report/Html/ClassView/Renderer/Dashboard.php +++ b/src/Report/Html/ClassView/Renderer/Dashboard.php @@ -14,7 +14,6 @@ use function htmlspecialchars; use function sprintf; use function str_repeat; -use function str_replace; use function substr_count; use function usort; use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; @@ -41,15 +40,15 @@ public function render(NamespaceNode $node, string $file): void $this->setCommonTemplateVariablesForNamespace($template, $node); - $baseLink = $node->id() . '/'; + $pathToRoot = $this->pathToRootForNamespace($node); $bubbleChart = new BubbleChart($this->thresholds); $template->setVar( [ - 'class_bubble_chart' => $bubbleChart->render($this->classItems($classes, $baseLink)), - 'class_crap_table' => $this->classCrapTable($classes, $baseLink), - 'method_bubble_chart' => $bubbleChart->render($this->methodItems($classes, $baseLink)), - 'method_crap_table' => $this->methodCrapTable($classes, $baseLink), + 'class_bubble_chart' => $bubbleChart->render($this->classItems($classes, $pathToRoot)), + 'class_crap_table' => $this->classCrapTable($classes, $pathToRoot), + 'method_bubble_chart' => $bubbleChart->render($this->methodItems($classes, $pathToRoot)), + 'method_crap_table' => $this->methodCrapTable($classes, $pathToRoot), ], ); @@ -120,7 +119,7 @@ protected function breadcrumbsForDashboard(NamespaceNode $node): string * * @return list */ - private function classItems(array $classes, string $baseLink): array + private function classItems(array $classes, string $pathToRoot): array { $items = []; @@ -134,7 +133,7 @@ private function classItems(array $classes, string $baseLink): array 'coverage' => $class->coverage, 'executableLines' => $class->executableLines, 'complexity' => $class->ccn, - 'link' => str_replace($baseLink, '', $class->link), + 'link' => $pathToRoot . $class->link, ]; } @@ -146,7 +145,7 @@ private function classItems(array $classes, string $baseLink): array * * @return list */ - private function methodItems(array $classes, string $baseLink): array + private function methodItems(array $classes, string $pathToRoot): array { $items = []; @@ -161,7 +160,7 @@ private function methodItems(array $classes, string $baseLink): array 'coverage' => $method->coverage, 'executableLines' => $method->executableLines, 'complexity' => $method->ccn, - 'link' => str_replace($baseLink, '', $method->link), + 'link' => $pathToRoot . $method->link, ]; } } @@ -172,7 +171,7 @@ private function methodItems(array $classes, string $baseLink): array /** * @param array $classes */ - private function classCrapTable(array $classes, string $baseLink): string + private function classCrapTable(array $classes, string $pathToRoot): string { $items = []; @@ -181,7 +180,7 @@ private function classCrapTable(array $classes, string $baseLink): string 'name' => $className, 'coverage' => $class->coverage, 'crap' => (new CrapIndex($class->ccn, $class->coverage))->asString(), - 'link' => str_replace($baseLink, '', $class->link), + 'link' => $pathToRoot . $class->link, ]; } @@ -191,7 +190,7 @@ private function classCrapTable(array $classes, string $baseLink): string /** * @param array $classes */ - private function methodCrapTable(array $classes, string $baseLink): string + private function methodCrapTable(array $classes, string $pathToRoot): string { $items = []; @@ -201,7 +200,7 @@ private function methodCrapTable(array $classes, string $baseLink): string 'name' => $className . '::' . $methodName, 'coverage' => $method->coverage, 'crap' => (new CrapIndex($method->ccn, $method->coverage))->asString(), - 'link' => str_replace($baseLink, '', $method->link), + 'link' => $pathToRoot . $method->link, ]; } } diff --git a/tests/_files/Report/HTML/CoverageForBankAccount/_classes/dashboard.html b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/dashboard.html index 29258e078..5741c3856 100644 --- a/tests/_files/Report/HTML/CoverageForBankAccount/_classes/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/dashboard.html @@ -70,7 +70,7 @@

Classes

Line Coverage (%) Cyclomatic Complexity - BankAccount — Coverage: 62.5% | Lines: 8 | Complexity: 5 + BankAccount — Coverage: 62.5% | Lines: 8 | Complexity: 5 @@ -87,7 +87,7 @@

 

- BankAccount6.3262.5% + BankAccount6.3262.5% @@ -133,10 +133,10 @@

Methods

Line Coverage (%) Cyclomatic Complexity - BankAccount::setBalance — Coverage: 0.0% | Lines: 3 | Complexity: 2 - BankAccount::depositMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 - BankAccount::withdrawMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 - BankAccount::getBalance — Coverage: 100.0% | Lines: 1 | Complexity: 1 + BankAccount::setBalance — Coverage: 0.0% | Lines: 3 | Complexity: 2 + BankAccount::depositMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 + BankAccount::withdrawMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 + BankAccount::getBalance — Coverage: 100.0% | Lines: 1 | Complexity: 1 @@ -153,10 +153,10 @@

 

- BankAccount::setBalance60.0% - BankAccount::getBalance1100.0% - BankAccount::depositMoney1100.0% - BankAccount::withdrawMoney1100.0% + BankAccount::setBalance60.0% + BankAccount::getBalance1100.0% + BankAccount::depositMoney1100.0% + BankAccount::withdrawMoney1100.0% diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html index edf4041d5..39d52e152 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html @@ -68,7 +68,7 @@

Classes

Line Coverage (%) Cyclomatic Complexity - CoveredClassWithAnonymousFunctionInStaticMethod — Coverage: 100.0% | Lines: 8 | Complexity: 1 + CoveredClassWithAnonymousFunctionInStaticMethod — Coverage: 100.0% | Lines: 8 | Complexity: 1 @@ -85,7 +85,7 @@

 

- CoveredClassWithAnonymousFunctionInStaticMethod1100.0% + CoveredClassWithAnonymousFunctionInStaticMethod1100.0% @@ -129,7 +129,7 @@

Methods

Line Coverage (%) Cyclomatic Complexity - CoveredClassWithAnonymousFunctionInStaticMethod::runAnonymous — Coverage: 100.0% | Lines: 8 | Complexity: 1 + CoveredClassWithAnonymousFunctionInStaticMethod::runAnonymous — Coverage: 100.0% | Lines: 8 | Complexity: 1 @@ -146,7 +146,7 @@

 

- CoveredClassWithAnonymousFunctionInStaticMethod::runAnonymous1100.0% + CoveredClassWithAnonymousFunctionInStaticMethod::runAnonymous1100.0% diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/dashboard.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/dashboard.html index 4d48a0299..d8aa0b27b 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/dashboard.html @@ -51,8 +51,8 @@

 

- Foo1100.0% - Bar1100.0% + Foo1100.0% + Bar1100.0% @@ -79,8 +79,8 @@

 

- Foo::bar1100.0% - Bar::foo1100.0% + Foo::bar1100.0% + Bar::foo1100.0% diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html index aec164df0..03d507e16 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html @@ -70,7 +70,7 @@

Classes

Line Coverage (%) Cyclomatic Complexity - BankAccount — Coverage: 42.9% | Lines: 8 | Complexity: 5 + BankAccount — Coverage: 42.9% | Lines: 8 | Complexity: 5 @@ -87,7 +87,7 @@

 

- BankAccount9.6642.9% + BankAccount9.6642.9% @@ -133,10 +133,10 @@

Methods

Line Coverage (%) Cyclomatic Complexity - BankAccount::setBalance — Coverage: 0.0% | Lines: 3 | Complexity: 2 - BankAccount::depositMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 - BankAccount::withdrawMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 - BankAccount::getBalance — Coverage: 100.0% | Lines: 1 | Complexity: 1 + BankAccount::setBalance — Coverage: 0.0% | Lines: 3 | Complexity: 2 + BankAccount::depositMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 + BankAccount::withdrawMoney — Coverage: 100.0% | Lines: 2 | Complexity: 1 + BankAccount::getBalance — Coverage: 100.0% | Lines: 1 | Complexity: 1 @@ -153,10 +153,10 @@

 

- BankAccount::setBalance60.0% - BankAccount::getBalance1100.0% - BankAccount::depositMoney1100.0% - BankAccount::withdrawMoney1100.0% + BankAccount::setBalance60.0% + BankAccount::getBalance1100.0% + BankAccount::depositMoney1100.0% + BankAccount::withdrawMoney1100.0% diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html index 98d56ee35..6347a2e27 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html @@ -51,7 +51,7 @@

 

- Foo0100.0% + Foo0100.0% diff --git a/tests/tests/Report/Html/ClassView/Renderer/DashboardTest.php b/tests/tests/Report/Html/ClassView/Renderer/DashboardTest.php index a40d203b9..96feaef85 100644 --- a/tests/tests/Report/Html/ClassView/Renderer/DashboardTest.php +++ b/tests/tests/Report/Html/ClassView/Renderer/DashboardTest.php @@ -54,6 +54,10 @@ public function testRendersRootDashboard(): void $this->assertNotFalse($output); $this->assertStringContainsString('App\\Root', $output); + + // Links point to file-view pages, which live one level above _classes/ + $this->assertStringContainsString('href="../t.php.html#1"', $output); + $this->assertStringNotContainsString('href="t.php.html#1"', $output); } public function testRendersNestedNamespaceBreadcrumbs(): void @@ -83,6 +87,9 @@ public function testRendersNestedNamespaceBreadcrumbs(): void $this->assertStringContainsString('(Dashboard)', $output); // pathToRoot for nested namespace + _classes $this->assertStringContainsString('../../../_css/', $output); + + // Links point to file-view pages, which live above _classes/App/Models/ + $this->assertStringContainsString('href="../../../t.php.html#1"', $output); } public function testRendersWithBranchCoverage(): void @@ -138,7 +145,7 @@ private function createClassNode(NamespaceNode $parent, string $className): Clas 1, 100, 1, - '', + 't.php.html#1', ); $processedClass = new ProcessedClassType( $className, @@ -154,7 +161,7 @@ private function createClassNode(NamespaceNode $parent, string $className): Clas 1, 100, 1, - '', + 't.php.html#1', ); $fileNode = new File('t.php', $root, 'a', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); From 405a85178d643345319b2066c1952d57e29af287 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 9 Jul 2026 09:57:57 +0200 Subject: [PATCH 03/12] Prevent endless loop in class view builder when static analysis data contains inheritance cycles --- src/Report/Html/ClassView/Builder.php | 7 ++- .../Report/Html/ClassView/BuilderTest.php | 60 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/Report/Html/ClassView/Builder.php b/src/Report/Html/ClassView/Builder.php index 530e43855..84d3b7b70 100644 --- a/src/Report/Html/ClassView/Builder.php +++ b/src/Report/Html/ClassView/Builder.php @@ -202,13 +202,18 @@ private function resolveParents(Class_ $class): array $seenMethods = $ownMethods; $currentClass = $class; + // Statically analysed code does not have to be runnable, it may declare inheritance cycles + $visited = [$class->namespacedName() => true]; + while ($currentClass->hasParent()) { $parentName = $currentClass->parentClass(); - if ($parentName === null || !isset($this->classRegistry[$parentName])) { + if ($parentName === null || isset($visited[$parentName]) || !isset($this->classRegistry[$parentName])) { break; } + $visited[$parentName] = true; + $parentEntry = $this->classRegistry[$parentName]; $parentRaw = $parentEntry['raw']; $parentProcessed = $parentEntry['processed']; diff --git a/tests/tests/Report/Html/ClassView/BuilderTest.php b/tests/tests/Report/Html/ClassView/BuilderTest.php index 6f740111f..ff064a37d 100644 --- a/tests/tests/Report/Html/ClassView/BuilderTest.php +++ b/tests/tests/Report/Html/ClassView/BuilderTest.php @@ -263,6 +263,66 @@ public function testBuildInheritanceSkipsOverriddenMethods(): void $this->assertCount(0, $childNode->parentSections()); } + public function testBuildTerminatesOnCyclicInheritance(): void + { + $root = new Directory('root'); + + $methodA = new Method('methodA', 1, 5, 'public function methodA(): void', Visibility::Public, 1); + $rawA = new Class_('A', 'App\\A', 'App', '/path/to/A.php', 1, 10, 'App\\B', [], [], ['methodA' => $methodA]); + + $methodB = new Method('methodB', 1, 5, 'public function methodB(): void', Visibility::Public, 1); + $rawB = new Class_('B', 'App\\B', 'App', '/path/to/B.php', 1, 10, 'App\\A', [], [], ['methodB' => $methodB]); + + $fileA = new File('A.php', $root, 'abc123', [], [], [], ['App\\A' => $rawA], [], [], new LinesOfCode(10, 0, 10)); + $root->addFile($fileA); + + $fileB = new File('B.php', $root, 'def456', [], [], [], ['App\\B' => $rawB], [], [], new LinesOfCode(10, 0, 10)); + $root->addFile($fileB); + + $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\\A', $classNodes); + $this->assertArrayHasKey('App\\B', $classNodes); + + // Each class inherits the other's method once, then the cycle is broken + $this->assertCount(1, $classNodes['App\\A']->parentSections()); + $this->assertCount(1, $classNodes['App\\B']->parentSections()); + } + + public function testBuildTerminatesOnSelfInheritance(): 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, 'App\\MyClass', [], [], ['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 testBuildWithSubdirectory(): void { $root = new Directory('root'); From 6a9a8caf44b254822fcdeca24bb8f0f268d7f88d Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 9 Jul 2026 10:06:17 +0200 Subject: [PATCH 04/12] Count each trait and each class's own code only once in class view namespace totals --- .../Html/ClassView/Node/NamespaceNode.php | 129 +++++++++++++----- .../Html/ClassView/Node/NamespaceNodeTest.php | 85 +++++++++++- 2 files changed, 173 insertions(+), 41 deletions(-) diff --git a/src/Report/Html/ClassView/Node/NamespaceNode.php b/src/Report/Html/ClassView/Node/NamespaceNode.php index 8e9d43d15..a2f0c02c9 100644 --- a/src/Report/Html/ClassView/Node/NamespaceNode.php +++ b/src/Report/Html/ClassView/Node/NamespaceNode.php @@ -13,9 +13,18 @@ use function str_replace; use Generator; use SebastianBergmann\CodeCoverage\Data\ProcessedClassType; +use SebastianBergmann\CodeCoverage\Data\ProcessedMethodType; +use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; use SebastianBergmann\CodeCoverage\Util\Percentage; /** + * Aggregated metrics count the own code of each class in the namespace exactly once and each + * trait used by those classes exactly once, even when a trait is used by multiple classes; + * code inherited from a parent class is counted at the node of the class that declares it. + * ClassNode metrics, in contrast, include the code a class uses from traits and inherits from + * parent classes, so the metrics of the classes in a namespace may add up to more than the + * metrics of the namespace itself. + * * @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 @@ -136,12 +145,12 @@ public function numberOfExecutableLines(): int if ($this->numExecutableLines === -1) { $this->numExecutableLines = 0; - foreach ($this->classes as $class) { - $this->numExecutableLines += $class->numberOfExecutableLines(); + foreach ($this->classesInSubtree() as $class) { + $this->numExecutableLines += $class->class_()->executableLines; } - foreach ($this->childNamespaces as $ns) { - $this->numExecutableLines += $ns->numberOfExecutableLines(); + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutableLines += $trait->executableLines; } } @@ -153,12 +162,12 @@ public function numberOfExecutedLines(): int if ($this->numExecutedLines === -1) { $this->numExecutedLines = 0; - foreach ($this->classes as $class) { - $this->numExecutedLines += $class->numberOfExecutedLines(); + foreach ($this->classesInSubtree() as $class) { + $this->numExecutedLines += $class->class_()->executedLines; } - foreach ($this->childNamespaces as $ns) { - $this->numExecutedLines += $ns->numberOfExecutedLines(); + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutedLines += $trait->executedLines; } } @@ -170,12 +179,12 @@ public function numberOfExecutableBranches(): int if ($this->numExecutableBranches === -1) { $this->numExecutableBranches = 0; - foreach ($this->classes as $class) { - $this->numExecutableBranches += $class->numberOfExecutableBranches(); + foreach ($this->classesInSubtree() as $class) { + $this->numExecutableBranches += $class->class_()->executableBranches; } - foreach ($this->childNamespaces as $ns) { - $this->numExecutableBranches += $ns->numberOfExecutableBranches(); + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutableBranches += $trait->executableBranches; } } @@ -187,12 +196,12 @@ public function numberOfExecutedBranches(): int if ($this->numExecutedBranches === -1) { $this->numExecutedBranches = 0; - foreach ($this->classes as $class) { - $this->numExecutedBranches += $class->numberOfExecutedBranches(); + foreach ($this->classesInSubtree() as $class) { + $this->numExecutedBranches += $class->class_()->executedBranches; } - foreach ($this->childNamespaces as $ns) { - $this->numExecutedBranches += $ns->numberOfExecutedBranches(); + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutedBranches += $trait->executedBranches; } } @@ -204,12 +213,12 @@ public function numberOfExecutablePaths(): int if ($this->numExecutablePaths === -1) { $this->numExecutablePaths = 0; - foreach ($this->classes as $class) { - $this->numExecutablePaths += $class->numberOfExecutablePaths(); + foreach ($this->classesInSubtree() as $class) { + $this->numExecutablePaths += $class->class_()->executablePaths; } - foreach ($this->childNamespaces as $ns) { - $this->numExecutablePaths += $ns->numberOfExecutablePaths(); + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutablePaths += $trait->executablePaths; } } @@ -221,12 +230,12 @@ public function numberOfExecutedPaths(): int if ($this->numExecutedPaths === -1) { $this->numExecutedPaths = 0; - foreach ($this->classes as $class) { - $this->numExecutedPaths += $class->numberOfExecutedPaths(); + foreach ($this->classesInSubtree() as $class) { + $this->numExecutedPaths += $class->class_()->executedPaths; } - foreach ($this->childNamespaces as $ns) { - $this->numExecutedPaths += $ns->numberOfExecutedPaths(); + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutedPaths += $trait->executedPaths; } } @@ -276,12 +285,10 @@ 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(); + foreach ($this->methodsInSubtree() as $method) { + if ($method->executableLines > 0) { + $this->numMethods++; + } } } @@ -293,12 +300,10 @@ 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(); + foreach ($this->methodsInSubtree() as $method) { + if ($method->executableLines > 0 && $method->coverage === 100) { + $this->numTestedMethods++; + } } } @@ -381,6 +386,58 @@ public function iterate(): Generator } } + /** + * @return list + */ + private function classesInSubtree(): array + { + $classes = $this->classes; + + foreach ($this->childNamespaces as $ns) { + $classes = array_merge($classes, $ns->classesInSubtree()); + } + + return $classes; + } + + /** + * @return array + */ + private function traitsInSubtree(): array + { + $traits = []; + + foreach ($this->classesInSubtree() as $class) { + foreach ($class->traitSections() as $section) { + $traits[$section->traitName] = $section->trait; + } + } + + return $traits; + } + + /** + * @return list + */ + private function methodsInSubtree(): array + { + $methods = []; + + foreach ($this->classesInSubtree() as $class) { + foreach ($class->class_()->methods as $method) { + $methods[] = $method; + } + } + + foreach ($this->traitsInSubtree() as $trait) { + foreach ($trait->methods as $method) { + $methods[] = $method; + } + } + + return $methods; + } + private function resetCounters(): void { $this->numExecutableLines = -1; diff --git a/tests/tests/Report/Html/ClassView/Node/NamespaceNodeTest.php b/tests/tests/Report/Html/ClassView/Node/NamespaceNodeTest.php index a20a1f50c..922fe1811 100644 --- a/tests/tests/Report/Html/ClassView/Node/NamespaceNodeTest.php +++ b/tests/tests/Report/Html/ClassView/Node/NamespaceNodeTest.php @@ -15,6 +15,7 @@ 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; @@ -309,6 +310,77 @@ public function testIterate(): void $this->assertSame($rootClass, $nodes[2]); } + public function testTraitUsedByMultipleClassesIsCountedOnlyOnceInAggregates(): void + { + $root = new NamespaceNode('Root', ''); + + $traitMethod = new ProcessedMethodType('traitMethod', 'public', 'public function traitMethod(): void', 1, 5, 4, 4, 2, 1, 2, 1, 1, 100, 1, ''); + $trait = new ProcessedTraitType('App\\MyTrait', 'App', ['traitMethod' => $traitMethod], 1, 4, 4, 2, 1, 2, 1, 1, 100, 1, ''); + + $classA = $this->createClassNode($root, 10, 5, 0, 0, 0, 0, null, 'App\\A', [new TraitSection('App\\MyTrait', '/path/to/MyTrait.php', 1, 10, $trait, $this->createFileNode())]); + $classB = $this->createClassNode($root, 20, 10, 0, 0, 0, 0, null, 'App\\B', [new TraitSection('App\\MyTrait', '/path/to/MyTrait.php', 1, 10, $trait, $this->createFileNode())]); + + $root->addClass($classA); + $root->addClass($classB); + + // Each class includes the trait in its own metrics ... + $this->assertSame(14, $classA->numberOfExecutableLines()); + $this->assertSame(24, $classB->numberOfExecutableLines()); + + // ... but the namespace counts the trait only once + $this->assertSame(34, $root->numberOfExecutableLines()); + $this->assertSame(19, $root->numberOfExecutedLines()); + $this->assertSame(2, $root->numberOfExecutableBranches()); + $this->assertSame(1, $root->numberOfExecutedBranches()); + $this->assertSame(2, $root->numberOfExecutablePaths()); + $this->assertSame(1, $root->numberOfExecutedPaths()); + $this->assertSame(3, $root->numberOfMethods()); + $this->assertSame(1, $root->numberOfTestedMethods()); + } + + public function testInheritedMethodsAreNotCountedTwiceInAggregates(): void + { + $root = new NamespaceNode('Root', ''); + + $parentMethod = new ProcessedMethodType('parentMethod', 'public', 'public function parentMethod(): void', 1, 5, 10, 5, 0, 0, 0, 0, 1, 50, 1, ''); + + $parentClass = $this->createClassNode($root, 10, 5, 0, 0, 0, 0, ['parentMethod' => $parentMethod], 'App\\ParentClass'); + $childClass = $this->createClassNode($root, 20, 10, 0, 0, 0, 0, null, 'App\\ChildClass', [], [new ParentSection('App\\ParentClass', '/path/to/ParentClass.php', ['parentMethod' => $parentMethod], $this->createFileNode())]); + + $root->addClass($parentClass); + $root->addClass($childClass); + + // The child includes the inherited method in its own metrics ... + $this->assertSame(30, $childClass->numberOfExecutableLines()); + + // ... but the namespace counts the parent's code only once + $this->assertSame(30, $root->numberOfExecutableLines()); + $this->assertSame(15, $root->numberOfExecutedLines()); + $this->assertSame(2, $root->numberOfMethods()); + } + + public function testTraitUsedInMultipleChildNamespacesIsCountedOnlyOnceInAggregates(): void + { + $root = new NamespaceNode('Root', ''); + $childA = new NamespaceNode('A', 'A', $root); + $childB = new NamespaceNode('B', 'B', $root); + + $trait = new ProcessedTraitType('MyTrait', '', [], 1, 4, 2, 0, 0, 0, 0, 1, 50, 1, ''); + + $childA->addClass($this->createClassNode($childA, 10, 5, 0, 0, 0, 0, null, 'A\\C', [new TraitSection('MyTrait', '/path/to/MyTrait.php', 1, 10, $trait, $this->createFileNode())])); + $childB->addClass($this->createClassNode($childB, 20, 10, 0, 0, 0, 0, null, 'B\\D', [new TraitSection('MyTrait', '/path/to/MyTrait.php', 1, 10, $trait, $this->createFileNode())])); + + $root->addNamespace($childA); + $root->addNamespace($childB); + + // Each child namespace counts the trait once ... + $this->assertSame(14, $childA->numberOfExecutableLines()); + $this->assertSame(24, $childB->numberOfExecutableLines()); + + // ... and so does their parent + $this->assertSame(34, $root->numberOfExecutableLines()); + } + public function testCountersResetWhenAddingClass(): void { $root = new NamespaceNode('Root', ''); @@ -337,11 +409,11 @@ public function testCountersResetWhenAddingNamespace(): void /** * @param null|array $methods * @param non-empty-string $className + * @param list $traitSections + * @param list $parentSections */ - 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 + 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', array $traitSections = [], array $parentSections = []): 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, ''), @@ -350,8 +422,11 @@ private function createClassNode(NamespaceNode $parent, int $executableLines = 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, $this->createFileNode(), $traitSections, $parentSections, $parent); + } - return new ClassNode($className, 'App', '/path/to/test.php', 1, 20, $processedClass, $fileNode, [], [], $parent); + private function createFileNode(): File + { + return new File('test.php', new Directory('root'), 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); } } From 3ecd98f954b6ab0e90d10cde9c0f4c5f11dd7560 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 9 Jul 2026 10:14:17 +0200 Subject: [PATCH 05/12] Handle class view expectations in script for regenerating HTML report expectations --- build/scripts/generate-expectations.php | 41 ++++++++++++++++--------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/build/scripts/generate-expectations.php b/build/scripts/generate-expectations.php index a55901476..d1870cda2 100755 --- a/build/scripts/generate-expectations.php +++ b/build/scripts/generate-expectations.php @@ -133,33 +133,46 @@ function formatMatches(string $format, string $subject): bool $expectationDirectory = $expectationPath . $expectationDirectoryName; - if (is_dir($expectationDirectory)) { - foreach (glob($expectationDirectory . DIRECTORY_SEPARATOR . '*') as $staleFile) { - unlink($staleFile); - } - } else { - mkdir($expectationDirectory, 0o777, true); - } + removeDirectory($expectationDirectory); + mkdir($expectationDirectory, 0o777, true); + + $generatedFiles = new RegexIterator( + new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($temporaryPath, RecursiveDirectoryIterator::SKIP_DOTS), + ), + '/\.html$/', + ); + + $numberOfFiles = 0; - foreach (glob($temporaryPath . DIRECTORY_SEPARATOR . '*.html') as $generatedFile) { - $name = basename($generatedFile); - $generated = str_replace(PHP_EOL, "\n", file_get_contents($generatedFile)); - $content = withPlaceholders($generated); + foreach ($generatedFiles as $generatedFile) { + $relativePath = substr($generatedFile->getPathname(), strlen($temporaryPath) + 1); + $generated = str_replace(PHP_EOL, "\n", file_get_contents($generatedFile->getPathname())); + $content = withPlaceholders($generated); if (!formatMatches($content, $generated)) { $content = collapseSourceListing($content); } if (!formatMatches($content, $generated)) { - print 'Format description for ' . $expectationDirectoryName . '/' . $name . ' does not match the generated file' . PHP_EOL; + print 'Format description for ' . $expectationDirectoryName . '/' . $relativePath . ' does not match the generated file' . PHP_EOL; exit(1); } - file_put_contents($expectationDirectory . DIRECTORY_SEPARATOR . $name, $content); + $targetFile = $expectationDirectory . DIRECTORY_SEPARATOR . $relativePath; + $targetDirectory = dirname($targetFile); + + if (!is_dir($targetDirectory)) { + mkdir($targetDirectory, 0o777, true); + } + + file_put_contents($targetFile, $content); + + $numberOfFiles++; } - print $expectationDirectoryName . ': ' . count(glob($expectationDirectory . DIRECTORY_SEPARATOR . '*.html')) . ' files' . PHP_EOL; + print $expectationDirectoryName . ': ' . $numberOfFiles . ' files' . PHP_EOL; } removeDirectory($temporaryPath); From 9b277ccd01292c58548c9d5a0382b30a1334f399 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 9 Jul 2026 10:14:36 +0200 Subject: [PATCH 06/12] Update expectations --- .../BankAccount.php.html | 59 +++++++++++- .../_classes/BankAccount.html | 55 +++++++++++- .../_classes/dashboard.html | 2 +- .../_classes/index.html | 2 +- .../HTML/CoverageForBankAccount/index.html | 2 +- ...ssWithAnonymousFunctionInStaticMethod.html | 39 +++++++- .../_classes/dashboard.html | 2 +- .../_classes/index.html | 2 +- .../index.html | 2 +- ...with_class_and_anonymous_function.php.html | 44 ++++++++- .../_classes/Bar.html | 31 ++++++- .../_classes/Foo.html | 28 +++++- .../_classes/dashboard.html | 2 +- .../_classes/index.html | 2 +- .../index.html | 2 +- .../source_with_ignore.php.html | 67 +++++++++++++- .../BankAccount.php.html | 2 +- .../BankAccount.php_branch.html | 2 +- .../BankAccount.php_path.html | 2 +- .../_classes/BankAccount.html | 55 +++++++++++- .../_classes/dashboard.html | 2 +- .../_classes/index.html | 2 +- .../PathCoverageForBankAccount/index.html | 2 +- .../_classes/Foo.html | 25 +++++- .../_classes/dashboard.html | 2 +- .../_classes/index.html | 2 +- .../index.html | 2 +- .../source_without_namespace.php.html | 43 ++++++++- .../source_without_namespace.php_branch.html | 89 +++++++++++++++++- .../source_without_namespace.php_path.html | 90 ++++++++++++++++++- 30 files changed, 625 insertions(+), 36 deletions(-) diff --git a/tests/_files/Report/HTML/CoverageForBankAccount/BankAccount.php.html b/tests/_files/Report/HTML/CoverageForBankAccount/BankAccount.php.html index 80dcf639c..ddf655457 100644 --- a/tests/_files/Report/HTML/CoverageForBankAccount/BankAccount.php.html +++ b/tests/_files/Report/HTML/CoverageForBankAccount/BankAccount.php.html @@ -2,7 +2,7 @@ - Code Coverage for %s%eBankAccount.php + Code Coverage for %sBankAccount.php @@ -196,6 +196,61 @@ -%a + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1<?php
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/BankAccount.html b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/BankAccount.html index 79a03a9c1..620f50d5d 100644 --- a/tests/_files/Report/HTML/CoverageForBankAccount/_classes/BankAccount.html +++ b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/BankAccount.html @@ -167,6 +167,59 @@ -%a + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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 index 5741c3856..ced2f9425 100644 --- a/tests/_files/Report/HTML/CoverageForBankAccount/_classes/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/dashboard.html @@ -165,7 +165,7 @@

 

diff --git a/tests/_files/Report/HTML/CoverageForBankAccount/_classes/index.html b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/index.html index 93ff9280a..db393bc1b 100644 --- a/tests/_files/Report/HTML/CoverageForBankAccount/_classes/index.html +++ b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/index.html @@ -115,7 +115,7 @@

Legend

High: 90% to 100%

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

diff --git a/tests/_files/Report/HTML/CoverageForBankAccount/index.html b/tests/_files/Report/HTML/CoverageForBankAccount/index.html index d857ff66a..a454df6b4 100644 --- a/tests/_files/Report/HTML/CoverageForBankAccount/index.html +++ b/tests/_files/Report/HTML/CoverageForBankAccount/index.html @@ -115,7 +115,7 @@

Legend

High: 90% to 100%

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

diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/CoveredClassWithAnonymousFunctionInStaticMethod.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/CoveredClassWithAnonymousFunctionInStaticMethod.html index 4105a0722..a5f05a3b8 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/CoveredClassWithAnonymousFunctionInStaticMethod.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/CoveredClassWithAnonymousFunctionInStaticMethod.html @@ -101,6 +101,43 @@ -%a + + + + + + + + + + + + + + + + + + + + + +
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 index 39d52e152..4faed3260 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html @@ -155,7 +155,7 @@

 

diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/index.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/index.html index 98af65068..92c18d4c2 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/index.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/index.html @@ -115,7 +115,7 @@

Legend

High: 90% to 100%

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

diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html index 25517115b..4876661fe 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html @@ -115,7 +115,7 @@

Legend

High: 90% to 100%

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

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 b04ec46c2..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 @@ -2,7 +2,7 @@ - Code Coverage for %s%esource_with_class_and_anonymous_function.php + Code Coverage for %ssource_with_class_and_anonymous_function.php @@ -130,6 +130,46 @@ -%a + + + + + + + + + + + + + + + + + + + + + + + +
1<?php
2
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/CoverageForFileWithIgnoredLines/_classes/Bar.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Bar.html index ccf781e37..309ea51e2 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Bar.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Bar.html @@ -76,6 +76,35 @@ -%a + + + + + + + + + + + + + +
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 index 0a56a6027..cfc3eccd8 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Foo.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Foo.html @@ -76,6 +76,32 @@ -%a + + + + + + + + + + +
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 index d8aa0b27b..dfd29c198 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/dashboard.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/dashboard.html @@ -89,7 +89,7 @@

 

diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/index.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/index.html index 35540b05b..6d6d55339 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/index.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/index.html @@ -98,7 +98,7 @@

Legend

High: 90% to 100%

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

diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html index 6e37bcc36..a29a50211 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/index.html @@ -105,7 +105,7 @@

Legend

High: 90% to 100%

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

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 0aff4cfb3..823a1a36b 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html @@ -2,7 +2,7 @@ - Code Coverage for %s%esource_with_ignore.php + Code Coverage for %ssource_with_ignore.php @@ -138,6 +138,69 @@ -%a + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1<?php
2if ($neverHappens) {
3    // @codeCoverageIgnoreStart
4    print '*';
5    // @codeCoverageIgnoreEnd
6}
7
8/**
9 * @codeCoverageIgnore
10 */
11class Foo
12{
13    public function bar()
14    {
15    }
16}
17
18class Bar
19{
20    /**
21     * @codeCoverageIgnore
22     */
23    public function foo()
24    {
25    }
26}
27
28function baz()
29{
30    print '*'; // @codeCoverageIgnore
31}
32
33interface Bor
34{
35    public function foo();
36}
37
38// @codeCoverageIgnoreStart
39print '
40Multiline
41';
42// @codeCoverageIgnoreEnd
+ + +
+
+

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/BankAccount.php.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html index 4e7df7dc6..bb02760dc 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html @@ -2,7 +2,7 @@ - Code Coverage for %s%eBankAccount.php + Code Coverage for %sBankAccount.php diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html index cc41f8356..17b1be9ae 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html @@ -2,7 +2,7 @@ - Code Coverage for %s%eBankAccount.php + Code Coverage for %sBankAccount.php diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html index cc41f8356..17b1be9ae 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html @@ -2,7 +2,7 @@ - Code Coverage for %s%eBankAccount.php + Code Coverage for %sBankAccount.php diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/BankAccount.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/BankAccount.html index 6dd1b1c8b..431c2056a 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/BankAccount.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/BankAccount.html @@ -249,6 +249,59 @@ -%a + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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 index 03d507e16..6734fbd40 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html @@ -165,7 +165,7 @@

 

diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/index.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/index.html index 49bd4e8b8..e7dd3df4d 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/index.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/index.html @@ -149,7 +149,7 @@

Legend

High: 90% to 100%

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

diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html index 75443813e..c74109d10 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html @@ -149,7 +149,7 @@

Legend

High: 90% to 100%

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

diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html index b6f4fad7f..ef0f3a776 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html @@ -72,6 +72,29 @@ -%a + + + + + + + +
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 index 6347a2e27..714624407 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html @@ -86,7 +86,7 @@

 

diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/index.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/index.html index 876aa4dd6..40b091e67 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/index.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/index.html @@ -99,7 +99,7 @@

Legend

High: 90% to 100%

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

diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html index 000d4423b..701024ee8 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html @@ -139,7 +139,7 @@

Legend

High: 90% to 100%

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

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 90c010e82..ae376b40c 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html @@ -2,7 +2,7 @@ - Code Coverage for %s%esource_without_namespace.php + Code Coverage for %ssource_without_namespace.php @@ -150,6 +150,45 @@ -%a + + + + + + + + + + + + + + + + + + + + + + +
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
+ + +
+
+

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/source_without_namespace.php_branch.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html index 90c010e82..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 @@ -2,7 +2,7 @@ - Code Coverage for %s%esource_without_namespace.php + Code Coverage for %ssource_without_namespace.php @@ -150,6 +150,91 @@ -%a + + + + + + + + + + + + + + + + + + + + + + +
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
+ +
+

Branches

+

+ Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not + necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. + Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement + always has an else as part of its logical flow even if you didn't write one. +

+
foo
+ + + + + + + + +
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
+ + + + + +
15    $a   = true ? true : false;
+ + + + + +
15    $a   = true ? true : false;
+ + + + + + + +
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
+
{closure:%ssource_without_namespace.php:14-14}
+ + + + + +
14    $baz = function () {};
+ + + + + + + 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 90c010e82..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 @@ -2,7 +2,7 @@ - Code Coverage for %s%esource_without_namespace.php + Code Coverage for %ssource_without_namespace.php @@ -150,6 +150,92 @@ -%a + + + + + + + + + + + + + + + + + + + + + + +
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
+ +
+

Paths

+

+ Below are the source code lines that represent each code path as identified by Xdebug. Please note a path is not + necessarily coterminous with a line, a line may contain multiple paths and therefore show up more than once. + Please also be aware that some paths may include implicit rather than explicit branches, e.g. an if statement + always has an else as part of its logical flow even if you didn't write one. +

+
foo
+ + + + + + + + + + + + + + +
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
 
15    $a   = true ? true : false;
 
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
+ + + + + + + + + + + + + + +
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
 
15    $a   = true ? true : false;
 
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
+
{closure:%ssource_without_namespace.php:14-14}
+ + + + + +
14    $baz = function () {};
+ + + + + + + From 1bd2970647ea1d76a557fc9a433b302783b15147 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 9 Jul 2026 10:20:27 +0200 Subject: [PATCH 07/12] Compare HTML report expectations recursively and verify that relative links in generated reports resolve --- tests/tests/Report/Html/EndToEndTest.php | 110 ++++++++++++++++------- 1 file changed, 80 insertions(+), 30 deletions(-) diff --git a/tests/tests/Report/Html/EndToEndTest.php b/tests/tests/Report/Html/EndToEndTest.php index 0defd33c2..865e2f097 100644 --- a/tests/tests/Report/Html/EndToEndTest.php +++ b/tests/tests/Report/Html/EndToEndTest.php @@ -10,16 +10,29 @@ namespace SebastianBergmann\CodeCoverage\Report\Html; use const DIRECTORY_SEPARATOR; +use const ENT_HTML5; +use const ENT_QUOTES; use const PHP_EOL; +use function array_keys; +use function dirname; +use function explode; use function file_get_contents; use function file_put_contents; -use function is_dir; -use function iterator_count; +use function html_entity_decode; +use function ksort; use function mkdir; +use function preg_match_all; +use function sprintf; +use function str_contains; use function str_replace; +use function str_starts_with; +use function strlen; +use function substr; use FilesystemIterator; use PHPUnit\Framework\Attributes\CoversNamespace; use PHPUnit\Framework\Attributes\Medium; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use RegexIterator; use SebastianBergmann\CodeCoverage\Data\ProcessedCodeCoverageData; use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; @@ -160,48 +173,85 @@ public function testHtmlSpecialCharactersInFileAndDirectoryNamesAreEncoded(): vo private function assertFilesEquals(string $expectedFilesPath, string $actualFilesPath): void { - $this->assertHtmlFilesEquals($expectedFilesPath, $actualFilesPath); + $expectedFiles = $this->htmlFiles($expectedFilesPath); + $actualFiles = $this->htmlFiles($actualFilesPath); - $expectedClassesPath = $expectedFilesPath . DIRECTORY_SEPARATOR . '_classes'; - $actualClassesPath = $actualFilesPath . DIRECTORY_SEPARATOR . '_classes'; + $this->assertSame( + array_keys($expectedFiles), + array_keys($actualFiles), + 'Generated files and expected files do not match', + ); + + foreach ($expectedFiles as $relativePath => $expectedFile) { + $actual = file_get_contents($actualFilesPath . DIRECTORY_SEPARATOR . $relativePath); + + $this->assertNotFalse($actual); - if (is_dir($expectedClassesPath)) { - $this->assertDirectoryExists($actualClassesPath); - $this->assertHtmlFilesEquals($expectedClassesPath, $actualClassesPath); + $this->assertStringMatchesFormatFile( + $expectedFile, + str_replace(PHP_EOL, "\n", $actual), + "{$relativePath} does not match", + ); } + + $this->assertRelativeLinkTargetsExist($actualFilesPath); } - private function assertHtmlFilesEquals(string $expectedFilesPath, string $actualFilesPath): void + private function assertRelativeLinkTargetsExist(string $reportPath): void { - $expectedFilesIterator = new RegexIterator(new FilesystemIterator($expectedFilesPath), '/\.html$/'); - $actualFilesIterator = new RegexIterator(new FilesystemIterator($actualFilesPath), '/\.html$/'); + foreach ($this->htmlFiles($reportPath) as $relativePath => $absolutePath) { + $html = file_get_contents($absolutePath); - $this->assertSame( - iterator_count($expectedFilesIterator), - iterator_count($actualFilesIterator), - 'Generated files and expected files not match', - ); + $this->assertNotFalse($html); - foreach ($expectedFilesIterator as $fileInfo) { - if (!$fileInfo instanceof SplFileInfo) { - continue; // @codeCoverageIgnore - } + preg_match_all('/(?:href|src)="([^"]+)"/', $html, $matches); - $filename = $fileInfo->getFilename(); + foreach ($matches[1] as $target) { + $target = html_entity_decode($target, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - $actualFile = $actualFilesPath . DIRECTORY_SEPARATOR . $filename; + if (str_starts_with($target, '#') || str_contains($target, '://')) { + continue; + } - $this->assertFileExists($actualFile); + $target = explode('#', $target, 2)[0]; + $target = explode('?', $target, 2)[0]; - $actual = file_get_contents($actualFile); + if ($target === '') { + continue; + } - $this->assertNotFalse($actual); + $this->assertFileExists( + dirname($reportPath . DIRECTORY_SEPARATOR . $relativePath) . DIRECTORY_SEPARATOR . $target, + sprintf('%s links to %s which does not exist', $relativePath, $target), + ); + } + } + } - $this->assertStringMatchesFormatFile( - $fileInfo->getPathname(), - str_replace(PHP_EOL, "\n", $actual), - "{$filename} not match", - ); + /** + * @return array Relative path mapped to absolute path, sorted by relative path + */ + private function htmlFiles(string $basePath): array + { + $iterator = new RegexIterator( + new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($basePath, FilesystemIterator::SKIP_DOTS), + ), + '/\.html$/', + ); + + $files = []; + + foreach ($iterator as $fileInfo) { + if (!$fileInfo instanceof SplFileInfo) { + continue; // @codeCoverageIgnore + } + + $files[substr($fileInfo->getPathname(), strlen($basePath) + 1)] = $fileInfo->getPathname(); } + + ksort($files); + + return $files; } } From fc5f9f63ba3f7b643f0f6c58f5bc2a3472ba9d79 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 9 Jul 2026 10:31:30 +0200 Subject: [PATCH 08/12] Add end-to-end test for class view with traits and inheritance --- build/scripts/generate-expectations.php | 11 +- .../ChildClass.php.html | 169 ++++++++++++++ .../ExampleClass.php.html | 194 ++++++++++++++++ .../ExampleTrait.php.html | 167 ++++++++++++++ .../ParentClass.php.html | 167 ++++++++++++++ .../_classes/ChildClass.html | 202 +++++++++++++++++ .../_classes/ExampleClass.html | 160 ++++++++++++++ .../_classes/ParentClass.html | 133 +++++++++++ .../_classes/dashboard.html | 175 +++++++++++++++ .../_classes/index.html | 179 +++++++++++++++ .../dashboard.html | 179 +++++++++++++++ .../index.html | 207 ++++++++++++++++++ tests/src/CoverageFixtureProvider.php | 42 ++++ tests/src/TestCase.php | 5 + tests/tests/Report/Html/EndToEndTest.php | 10 + 15 files changed, 1995 insertions(+), 5 deletions(-) create mode 100644 tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ChildClass.php.html create mode 100644 tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleClass.php.html create mode 100644 tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleTrait.php.html create mode 100644 tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ParentClass.php.html create mode 100644 tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ChildClass.html create mode 100644 tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ExampleClass.html create mode 100644 tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ParentClass.html create mode 100644 tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/dashboard.html create mode 100644 tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/index.html create mode 100644 tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/dashboard.html create mode 100644 tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/index.html diff --git a/build/scripts/generate-expectations.php b/build/scripts/generate-expectations.php index d1870cda2..702271f51 100755 --- a/build/scripts/generate-expectations.php +++ b/build/scripts/generate-expectations.php @@ -116,11 +116,12 @@ function formatMatches(string $format, string $subject): bool $provider = new CoverageFixtureProvider; $scenarios = [ - 'CoverageForBankAccount' => $provider->lineCoverageForBankAccount(...), - 'PathCoverageForBankAccount' => $provider->pathCoverageForBankAccount(...), - 'PathCoverageForSourceWithoutNamespace' => $provider->pathCoverageForSourceWithoutNamespace(...), - 'CoverageForFileWithIgnoredLines' => $provider->coverageForFileWithIgnoredLines(...), - 'CoverageForClassWithAnonymousFunction' => $provider->coverageForClassWithAnonymousFunction(...), + 'CoverageForBankAccount' => $provider->lineCoverageForBankAccount(...), + 'PathCoverageForBankAccount' => $provider->pathCoverageForBankAccount(...), + 'PathCoverageForSourceWithoutNamespace' => $provider->pathCoverageForSourceWithoutNamespace(...), + 'CoverageForFileWithIgnoredLines' => $provider->coverageForFileWithIgnoredLines(...), + 'CoverageForClassWithAnonymousFunction' => $provider->coverageForClassWithAnonymousFunction(...), + 'CoverageForClassesWithTraitsAndInheritance' => $provider->coverageForClassesWithTraitsAndInheritance(...), ]; $expectationPath = TEST_FILES_PATH . 'Report' . DIRECTORY_SEPARATOR . 'HTML' . DIRECTORY_SEPARATOR; diff --git a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ChildClass.php.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ChildClass.php.html new file mode 100644 index 000000000..df6216f45 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ChildClass.php.html @@ -0,0 +1,169 @@ + + + + + Code Coverage for %sClassView/ChildClass.php + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
CRAP
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
ChildClass
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
 childMethod
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
+
+ + + + + + + + + + + + + + + + + +
1<?php declare(strict_types=1);
2
3namespace Example;
4
5class ChildClass extends ParentClass
6{
7    use ExampleTrait;
8
9    public function childMethod(): void
10    {
11        echo 'child';
12    }
13}
+ + +
+
+

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/CoverageForClassesWithTraitsAndInheritance/ExampleClass.php.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleClass.php.html new file mode 100644 index 000000000..608f19321 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleClass.php.html @@ -0,0 +1,194 @@ + + + + + Code Coverage for %sClassView/ExampleClass.php + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
+
+ 50.00% covered (danger) +
+
+
50.00%
1 / 2
+
+ 50.00% covered (danger) +
+
+
50.00%
1 / 2
CRAP
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
ExampleClass
+
+ 50.00% covered (danger) +
+
+
50.00%
1 / 2
+
+ 50.00% covered (danger) +
+
+
50.00%
1 / 2
2.50
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
 doSomething
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
 doMore
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
2
+
+ + + + + + + + + + + + + + + + + + + + +
1<?php declare(strict_types=1);
2
3namespace Example;
4
5class ExampleClass
6{
7    public function doSomething(): void
8    {
9        echo 'hello';
10    }
11
12    public function doMore(): int
13    {
14        return 42;
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/CoverageForClassesWithTraitsAndInheritance/ExampleTrait.php.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleTrait.php.html new file mode 100644 index 000000000..0ed1d2c19 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleTrait.php.html @@ -0,0 +1,167 @@ + + + + + Code Coverage for %sClassView/ExampleTrait.php + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
CRAP
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
ExampleTrait
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
 traitMethod
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
+
+ + + + + + + + + + + + + + + +
1<?php declare(strict_types=1);
2
3namespace Example;
4
5trait ExampleTrait
6{
7    public function traitMethod(): string
8    {
9        return 'trait';
10    }
11}
+ + +
+
+

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/CoverageForClassesWithTraitsAndInheritance/ParentClass.php.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ParentClass.php.html new file mode 100644 index 000000000..b69b4e99c --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ParentClass.php.html @@ -0,0 +1,167 @@ + + + + + Code Coverage for %sClassView/ParentClass.php + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
CRAP
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
ParentClass
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
2
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
 parentMethod
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
2
+
+ + + + + + + + + + + + + + + +
1<?php declare(strict_types=1);
2
3namespace Example;
4
5class ParentClass
6{
7    public function parentMethod(): void
8    {
9        echo 'parent';
10    }
11}
+ + +
+
+

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/CoverageForClassesWithTraitsAndInheritance/_classes/ChildClass.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ChildClass.html new file mode 100644 index 000000000..18e555de9 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ChildClass.html @@ -0,0 +1,202 @@ + + + + + Code Coverage for Example\ChildClass + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
+
+ 66.67% covered (warning) +
+
+
66.67%
2 / 3
+
+ 66.67% covered (warning) +
+
+
66.67%
2 / 3
CRAP
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
 childMethod
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
 [Example\ExampleTrait] traitMethod
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
 [Example\ParentClass] parentMethod
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
2
+
+ + + + + + + + + + + + + +
5class ChildClass extends ParentClass
6{
7    use ExampleTrait;
8
9    public function childMethod(): void
10    {
11        echo 'child';
12    }
13}
+

From Example\ExampleTrait

+ + + + + + + + + + + +
5trait ExampleTrait
6{
7    public function traitMethod(): string
8    {
9        return 'trait';
10    }
11}
+

Inherited from Example\ParentClass

+ + + + + + + + +
7    public function parentMethod(): void
8    {
9        echo 'parent';
10    }
+ +
+
+

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/CoverageForClassesWithTraitsAndInheritance/_classes/ExampleClass.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ExampleClass.html new file mode 100644 index 000000000..ac780970e --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ExampleClass.html @@ -0,0 +1,160 @@ + + + + + Code Coverage for Example\ExampleClass + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
+
+ 50.00% covered (danger) +
+
+
50.00%
1 / 2
+
+ 50.00% covered (danger) +
+
+
50.00%
1 / 2
CRAP
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
 doSomething
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
 doMore
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
2
+
+ + + + + + + + + + + + + + + + +
5class ExampleClass
6{
7    public function doSomething(): void
8    {
9        echo 'hello';
10    }
11
12    public function doMore(): int
13    {
14        return 42;
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/CoverageForClassesWithTraitsAndInheritance/_classes/ParentClass.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ParentClass.html new file mode 100644 index 000000000..b3469b638 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ParentClass.html @@ -0,0 +1,133 @@ + + + + + Code Coverage for Example\ParentClass + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
CRAP
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
 parentMethod
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
2
+
+ + + + + + + + + + + +
5class ParentClass
6{
7    public function parentMethod(): void
8    {
9        echo 'parent';
10    }
11}
+ +
+
+

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/CoverageForClassesWithTraitsAndInheritance/_classes/dashboard.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/dashboard.html new file mode 100644 index 000000000..264951b93 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/dashboard.html @@ -0,0 +1,175 @@ + + + + + Dashboard for Example + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ + + + 0% + + 20% + + 40% + + 60% + + 80% + + 100% + + 0 + + 1 + + 2 + + 3 + + + Line Coverage (%) + Cyclomatic Complexity + Example\ExampleClass — Coverage: 50.0% | Lines: 2 | Complexity: 2 + Example\ChildClass — Coverage: 100.0% | Lines: 1 | Complexity: 1 + Example\ParentClass — Coverage: 0.0% | Lines: 1 | Complexity: 1 + +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + + + + +
ClassCRAPCoverage
Example\ExampleClass2.5050.0%
Example\ParentClass20.0%
Example\ChildClass1100.0%
+
+
+
+
+
+

Methods

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ + + + 0% + + 20% + + 40% + + 60% + + 80% + + 100% + + 0 + + 1 + + 2 + + + Line Coverage (%) + Cyclomatic Complexity + Example\ChildClass::childMethod — Coverage: 100.0% | Lines: 1 | Complexity: 1 + Example\ExampleClass::doSomething — Coverage: 100.0% | Lines: 1 | Complexity: 1 + Example\ExampleClass::doMore — Coverage: 0.0% | Lines: 1 | Complexity: 1 + Example\ParentClass::parentMethod — Coverage: 0.0% | Lines: 1 | Complexity: 1 + +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+ +
+
+ +
+ + diff --git a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/index.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/index.html new file mode 100644 index 000000000..6ad024ccc --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/index.html @@ -0,0 +1,179 @@ + + + + + Code Coverage for Example + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
+
+ 60.00% covered (warning) +
+
+
60.00%
3 / 5
+
+ 60.00% covered (warning) +
+
+
60.00%
3 / 5
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 3
ChildClass
+
+ 66.67% covered (warning) +
+
+
66.67%
2 / 3
+
+ 66.67% covered (warning) +
+
+
66.67%
2 / 3
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
ExampleClass
+
+ 50.00% covered (danger) +
+
+
50.00%
1 / 2
+
+ 50.00% covered (danger) +
+
+
50.00%
1 / 2
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
ParentClass
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 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/CoverageForClassesWithTraitsAndInheritance/dashboard.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/dashboard.html new file mode 100644 index 000000000..a276a9998 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/dashboard.html @@ -0,0 +1,179 @@ + + + + + Dashboard for %sClassView + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+

Classes

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ + + + 0% + + 20% + + 40% + + 60% + + 80% + + 100% + + 0 + + 1 + + 2 + + 3 + + + Line Coverage (%) + Cyclomatic Complexity + Example\ExampleClass — Coverage: 50.0% | Lines: 2 | Complexity: 2 + Example\ChildClass — Coverage: 100.0% | Lines: 1 | Complexity: 1 + Example\ParentClass — Coverage: 0.0% | Lines: 1 | Complexity: 1 + Example\ExampleTrait — Coverage: 100.0% | Lines: 1 | Complexity: 1 + +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+
+ + + + + + + + + + + + + + +
ClassCRAPCoverage
Example\ExampleClass2.5050.0%
Example\ParentClass20.0%
Example\ChildClass1100.0%
Example\ExampleTrait1100.0%
+
+
+
+
+
+

Methods

+

Bubble size = executable lines. Hover for details, click to navigate.

+
+ + + + 0% + + 20% + + 40% + + 60% + + 80% + + 100% + + 0 + + 1 + + 2 + + + Line Coverage (%) + Cyclomatic Complexity + Example\ChildClass::childMethod — Coverage: 100.0% | Lines: 1 | Complexity: 1 + Example\ExampleClass::doSomething — Coverage: 100.0% | Lines: 1 | Complexity: 1 + Example\ExampleClass::doMore — Coverage: 0.0% | Lines: 1 | Complexity: 1 + Example\ParentClass::parentMethod — Coverage: 0.0% | Lines: 1 | Complexity: 1 + Example\ExampleTrait::traitMethod — Coverage: 100.0% | Lines: 1 | Complexity: 1 + +
+
+
+

 

+

Sorted by descending CRAP (Change Risk Anti-Patterns) index.

+ +
+
+ +
+ + diff --git a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/index.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/index.html new file mode 100644 index 000000000..76f0925e7 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/index.html @@ -0,0 +1,207 @@ + + + + + Code Coverage for %sClassView + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
+
+ 60.00% covered (warning) +
+
+
60.00%
3 / 5
+
+ 60.00% covered (warning) +
+
+
60.00%
3 / 5
+
+ 50.00% covered (danger) +
+
+
50.00%
2 / 4
ChildClass.php
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
ExampleClass.php
+
+ 50.00% covered (danger) +
+
+
50.00%
1 / 2
+
+ 50.00% covered (danger) +
+
+
50.00%
1 / 2
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
ExampleTrait.php
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
ParentClass.php
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
+
+ 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/src/CoverageFixtureProvider.php b/tests/src/CoverageFixtureProvider.php index 00fc7a9ae..3efb037a2 100644 --- a/tests/src/CoverageFixtureProvider.php +++ b/tests/src/CoverageFixtureProvider.php @@ -1224,6 +1224,25 @@ public function coverageForClassWithAnonymousFunction(): CodeCoverage return $coverage; } + public function coverageForClassesWithTraitsAndInheritance(): CodeCoverage + { + $filter = new Filter; + $filter->includeFile(TEST_FILES_PATH . 'ClassView/ChildClass.php'); + $filter->includeFile(TEST_FILES_PATH . 'ClassView/ExampleClass.php'); + $filter->includeFile(TEST_FILES_PATH . 'ClassView/ExampleTrait.php'); + $filter->includeFile(TEST_FILES_PATH . 'ClassView/ParentClass.php'); + + $coverage = new CodeCoverage( + $this->xdebugFakeForClassesWithTraitsAndInheritance(), + $filter, + ); + + $coverage->start('ClassesWithTraitsAndInheritance', null, true); + $coverage->stop(); + + return $coverage; + } + /** * Records line coverage for BankAccount.php where the lines are covered by * tests of different sizes (large, medium, small) and statuses (success and @@ -1351,6 +1370,29 @@ private function xdebugFakeForFileWithIgnoredLines(): Driver ); } + private function xdebugFakeForClassesWithTraitsAndInheritance(): Driver + { + return new FakeDriver( + RawCodeCoverageData::fromXdebugWithoutPathCoverage( + [ + TEST_FILES_PATH . 'ClassView/ChildClass.php' => [ + 11 => 1, + ], + TEST_FILES_PATH . 'ClassView/ExampleClass.php' => [ + 9 => 1, + 14 => -1, + ], + TEST_FILES_PATH . 'ClassView/ExampleTrait.php' => [ + 9 => 1, + ], + TEST_FILES_PATH . 'ClassView/ParentClass.php' => [ + 9 => -1, + ], + ], + ), + ); + } + private function xdebugFakeForClassWithAnonymousFunction(): Driver { return new FakeDriver( diff --git a/tests/src/TestCase.php b/tests/src/TestCase.php index cc5b496d4..4c0739760 100644 --- a/tests/src/TestCase.php +++ b/tests/src/TestCase.php @@ -699,6 +699,11 @@ protected function getCoverageForClassWithAnonymousFunction(): CodeCoverage return new CoverageFixtureProvider()->coverageForClassWithAnonymousFunction(); } + protected function getCoverageForClassesWithTraitsAndInheritance(): CodeCoverage + { + return new CoverageFixtureProvider()->coverageForClassesWithTraitsAndInheritance(); + } + protected function getCoverageForClassWithOutsideFunction(): CodeCoverage { $filter = new Filter; diff --git a/tests/tests/Report/Html/EndToEndTest.php b/tests/tests/Report/Html/EndToEndTest.php index 865e2f097..fef6c3bbc 100644 --- a/tests/tests/Report/Html/EndToEndTest.php +++ b/tests/tests/Report/Html/EndToEndTest.php @@ -138,6 +138,16 @@ public function testForClassWithAnonymousFunction(): void $this->assertFilesEquals($expectedFilesPath, TEST_FILES_PATH . 'tmp'); } + public function testForClassesWithTraitsAndInheritance(): void + { + $expectedFilesPath = TEST_FILES_PATH . 'Report' . DIRECTORY_SEPARATOR . 'HTML' . DIRECTORY_SEPARATOR . 'CoverageForClassesWithTraitsAndInheritance'; + + $report = new Facade; + $report->process($this->getCoverageForClassesWithTraitsAndInheritance()->getReport(), TEST_FILES_PATH . 'tmp'); + + $this->assertFilesEquals($expectedFilesPath, TEST_FILES_PATH . 'tmp'); + } + public function testHtmlSpecialCharactersInFileAndDirectoryNamesAreEncoded(): void { if (DIRECTORY_SEPARATOR === '\\') { From d2ac530369408a10120a9425938f9a1b65a8a5f9 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 9 Jul 2026 10:37:50 +0200 Subject: [PATCH 09/12] Cover File::rawClasses() and File::rawTraits() with direct tests --- tests/tests/Node/FileTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/tests/Node/FileTest.php b/tests/tests/Node/FileTest.php index 6f1fb193b..e57ab2f1a 100644 --- a/tests/tests/Node/FileTest.php +++ b/tests/tests/Node/FileTest.php @@ -445,6 +445,20 @@ public function testNumberOfTestedMethodsIncludesFullyCoveredTraitMethods(): voi $this->assertSame(1, $file->numberOfTestedMethods()); } + public function testRawClassesCanBeQueried(): void + { + $file = $this->createFileNodeWithTestedClassAndTrait(); + + $this->assertArrayHasKey('MyClass', $file->rawClasses()); + } + + public function testRawTraitsCanBeQueried(): void + { + $file = $this->createFileNodeWithTestedClassAndTrait(); + + $this->assertArrayHasKey('MyTrait', $file->rawTraits()); + } + private function createFileNodeWithTestedClassAndTrait(): File { $root = new Directory('root'); From 6971cacbda7530dc72be860d745876f85c0ac6ae Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 9 Jul 2026 10:45:55 +0200 Subject: [PATCH 10/12] Prefix line anchors of trait and parent sections on class pages to keep them unique --- src/Report/Html/ClassView/Renderer/Class_.php | 21 +++++++++------ src/Report/Html/Renderer.php | 3 ++- .../Html/Renderer/Template/line.html.dist | 2 +- .../_classes/ChildClass.html | 26 +++++++++---------- .../Html/ClassView/Renderer/Class_Test.php | 22 ++++++++++++++++ 5 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/Report/Html/ClassView/Renderer/Class_.php b/src/Report/Html/ClassView/Renderer/Class_.php index d82909695..8e315d026 100644 --- a/src/Report/Html/ClassView/Renderer/Class_.php +++ b/src/Report/Html/ClassView/Renderer/Class_.php @@ -152,23 +152,25 @@ private function renderItems(ClassNode $node): string } // Trait methods - foreach ($node->traitSections() as $section) { + foreach ($node->traitSections() as $i => $section) { foreach ($section->trait->methods as $methodName => $method) { $items .= $this->renderMethodItem( $methodItemTemplate, $method, ' [' . htmlspecialchars($section->traitName, self::HTML_SPECIAL_CHARS_FLAGS) . '] ', + 'trait-' . $i . '-', ); } } // Inherited methods - foreach ($node->parentSections() as $section) { + foreach ($node->parentSections() as $i => $section) { foreach ($section->methods as $methodName => $method) { $items .= $this->renderMethodItem( $methodItemTemplate, $method, ' [' . htmlspecialchars($section->className, self::HTML_SPECIAL_CHARS_FLAGS) . '] ', + 'parent-' . $i . '-', ); } } @@ -176,7 +178,7 @@ private function renderItems(ClassNode $node): string return $items; } - private function renderMethodItem(Template $template, ProcessedMethodType $method, string $indent = ' '): string + private function renderMethodItem(Template $template, ProcessedMethodType $method, string $indent = ' ', string $anchorPrefix = ''): string { $numMethods = 0; $numTestedMethods = 0; @@ -213,8 +215,9 @@ private function renderMethodItem(Template $template, ProcessedMethodType $metho $template, [ 'name' => sprintf( - '%s%s', + '%s%s', $indent, + $anchorPrefix, $method->startLine, htmlspecialchars($method->signature, self::HTML_SPECIAL_CHARS_FLAGS), $method->methodName, @@ -255,7 +258,7 @@ private function renderSourceSections(ClassNode $node): string ); // Trait source sections - foreach ($node->traitSections() as $section) { + foreach ($node->traitSections() as $i => $section) { $sections .= $this->renderSectionHeader('From ' . $section->traitName); $sections .= $this->renderSourceSection( @@ -265,11 +268,12 @@ private function renderSourceSections(ClassNode $node): string $section->endLine, $section->fileNode->lineCoverageData(), $section->fileNode->testData(), + 'trait-' . $i . '-', ); } // Parent source sections - foreach ($node->parentSections() as $section) { + foreach ($node->parentSections() as $i => $section) { $sections .= $this->renderSectionHeader('Inherited from ' . $section->className); foreach ($section->methods as $method) { @@ -280,6 +284,7 @@ private function renderSourceSections(ClassNode $node): string $method->endLine, $section->fileNode->lineCoverageData(), $section->fileNode->testData(), + 'parent-' . $i . '-', ); } } @@ -300,7 +305,7 @@ private function renderSectionHeader(string $title): string * @param array> $coverageData * @param array $testData */ - private function renderSourceSection(string $label, string $filePath, int $startLine, int $endLine, array $coverageData, array $testData): string + private function renderSourceSection(string $label, string $filePath, int $startLine, int $endLine, array $coverageData, array $testData, string $anchorPrefix = ''): string { $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}'); @@ -363,7 +368,7 @@ private function renderSourceSection(string $label, string $filePath, int $start ); } - $lines .= $this->renderLine($singleLineTemplate, $i, $codeLines[$lineIndex], $trClass, $popover); + $lines .= $this->renderLine($singleLineTemplate, $i, $codeLines[$lineIndex], $trClass, $popover, $anchorPrefix); } $linesTemplate->setVar(['lines' => $lines]); diff --git a/src/Report/Html/Renderer.php b/src/Report/Html/Renderer.php index 763674bbc..0cbe8c981 100644 --- a/src/Report/Html/Renderer.php +++ b/src/Report/Html/Renderer.php @@ -389,10 +389,11 @@ protected function colorLevel(float $percent): string return 'success'; } - protected function renderLine(Template $template, int $lineNumber, string $lineContent, string $class, string $popover): string + protected function renderLine(Template $template, int $lineNumber, string $lineContent, string $class, string $popover, string $anchorPrefix = ''): string { $template->setVar( [ + 'anchor' => $anchorPrefix . $lineNumber, 'lineNumber' => (string) $lineNumber, 'lineContent' => $lineContent, 'class' => $class, diff --git a/src/Report/Html/Renderer/Template/line.html.dist b/src/Report/Html/Renderer/Template/line.html.dist index d7e055f63..e6637bfa8 100644 --- a/src/Report/Html/Renderer/Template/line.html.dist +++ b/src/Report/Html/Renderer/Template/line.html.dist @@ -1 +1 @@ - {{lineNumber}}{{lineContent}} + {{lineNumber}}{{lineContent}} diff --git a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ChildClass.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ChildClass.html index 18e555de9..d209e4563 100644 --- a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ChildClass.html +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/_classes/ChildClass.html @@ -98,7 +98,7 @@ -  [Example\ExampleTrait] traitMethod +  [Example\ExampleTrait] traitMethod
100.00% covered (success) @@ -120,7 +120,7 @@ -  [Example\ParentClass] parentMethod +  [Example\ParentClass] parentMethod
0.00% covered (danger) @@ -162,23 +162,23 @@

From Example\ExampleTrait

- - - - - - - + + + + + + +
5trait ExampleTrait
6{
7    public function traitMethod(): string
8    {
9        return 'trait';
10    }
11}
5trait ExampleTrait
6{
7    public function traitMethod(): string
8    {
9        return 'trait';
10    }
11}

Inherited from Example\ParentClass

- - - - + + + +
7    public function parentMethod(): void
8    {
9        echo 'parent';
10    }
7    public function parentMethod(): void
8    {
9        echo 'parent';
10    }
diff --git a/tests/tests/Report/Html/ClassView/Renderer/Class_Test.php b/tests/tests/Report/Html/ClassView/Renderer/Class_Test.php index 55dd2d654..6ae292aba 100644 --- a/tests/tests/Report/Html/ClassView/Renderer/Class_Test.php +++ b/tests/tests/Report/Html/ClassView/Renderer/Class_Test.php @@ -10,8 +10,12 @@ namespace SebastianBergmann\CodeCoverage\Report\Html\ClassView\Renderer; use const DIRECTORY_SEPARATOR; +use function array_filter; +use function array_unique; +use function array_values; use function file_get_contents; use function is_file; +use function preg_match_all; use function sys_get_temp_dir; use function tempnam; use function unlink; @@ -61,6 +65,24 @@ public function testRendersClassWithTraitAndParentSectionsInNestedNamespace(): v $this->assertStringContainsString('[Example\\ParentClass]', $output); // pathToRoot uses one extra level for nested namespace + _classes $this->assertStringContainsString('../../../_css/', $output); + + // Line anchors in trait and parent sections are prefixed so that they do not + // collide with the line anchors of the class's own source section + $this->assertStringContainsString('id="trait-0-5"', $output); + $this->assertStringContainsString('href="#trait-0-', $output); + $this->assertStringContainsString('id="parent-0-', $output); + $this->assertStringContainsString('href="#parent-0-', $output); + + preg_match_all('/ id="([^"]+)"/', $output, $matches); + + $anchors = array_values( + array_filter( + $matches[1], + static fn (string $id): bool => $id !== 'code', + ), + ); + + $this->assertSame(array_values(array_unique($anchors)), $anchors); } public function testRendersClassWithBranchCoverageTemplate(): void From c49125409887a5e89663df519f2944b291d24ce1 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 9 Jul 2026 10:50:53 +0200 Subject: [PATCH 11/12] Add option to disable the generation of the class-oriented HTML report --- src/Report/Html/Facade.php | 25 ++++++++++++++++++----- src/Report/Html/Renderer.php | 8 +++++++- tests/tests/Report/Html/FacadeTest.php | 28 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/Report/Html/Facade.php b/src/Report/Html/Facade.php index e46048ad2..cebc94810 100644 --- a/src/Report/Html/Facade.php +++ b/src/Report/Html/Facade.php @@ -43,13 +43,15 @@ private Colors $colors; private Thresholds $thresholds; private CustomCssFile $customCssFile; + private bool $classView; - public function __construct(string $generator = '', ?Colors $colors = null, ?Thresholds $thresholds = null, ?CustomCssFile $customCssFile = null) + public function __construct(string $generator = '', ?Colors $colors = null, ?Thresholds $thresholds = null, ?CustomCssFile $customCssFile = null, bool $classView = true) { $this->generator = $generator; $this->colors = $colors ?? Colors::default(); $this->thresholds = $thresholds ?? Thresholds::default(); $this->customCssFile = $customCssFile ?? CustomCssFile::default(); + $this->classView = $classView; $this->templatePath = __DIR__ . '/Renderer/Template/'; } @@ -60,12 +62,19 @@ public function process(DirectoryNode $report, string $target): void $hasBranchCoverage = $report->numberOfExecutableBranches() > 0; $hasPathCoverage = $report->numberOfExecutablePaths() > 0; - $builder = new Builder; - $rootNamespace = $builder->build($report); - $fileToClassMap = $this->buildFileToClassMap($rootNamespace); + $fileToClassMap = []; + + if ($this->classView) { + $rootNamespace = new Builder()->build($report); + $fileToClassMap = $this->buildFileToClassMap($rootNamespace); + } $this->renderFileView($report, $target, $date, $hasBranchCoverage, $hasPathCoverage, $fileToClassMap); - $this->renderClassView($rootNamespace, $target, $date, $hasBranchCoverage, $hasPathCoverage); + + if (isset($rootNamespace)) { + $this->renderClassView($rootNamespace, $target, $date, $hasBranchCoverage, $hasPathCoverage); + } + $this->copyFiles($target); $this->renderCss($target); } @@ -81,6 +90,12 @@ private function renderFileView(DirectoryNode $report, string $target, string $d $file->setFileToClassMap($fileToClassMap); + if (!$this->classView) { + $dashboard->disableClassView(); + $directory->disableClassView(); + $file->disableClassView(); + } + $directory->render($report, $target . 'index.html'); $dashboard->render($report, $target . 'dashboard.html'); diff --git a/src/Report/Html/Renderer.php b/src/Report/Html/Renderer.php index 0cbe8c981..5026b88ea 100644 --- a/src/Report/Html/Renderer.php +++ b/src/Report/Html/Renderer.php @@ -77,6 +77,7 @@ abstract class Renderer * @var array */ private array $fileToClassMap = []; + private bool $classView = true; public function __construct(string $templatePath, string $generator, string $date, Thresholds $thresholds, bool $hasBranchCoverage, bool $hasPathCoverage) { @@ -98,6 +99,11 @@ public function setFileToClassMap(array $map): void $this->fileToClassMap = $map; } + public function disableClassView(): void + { + $this->classView = false; + } + /** * @return non-empty-string */ @@ -263,7 +269,7 @@ 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), + 'view_switcher' => $this->classView ? $this->viewSwitcher($pathToRoot, 'files', 'index.html', $classesTarget) : '', ], ); } diff --git a/tests/tests/Report/Html/FacadeTest.php b/tests/tests/Report/Html/FacadeTest.php index 608579206..11b8fae6f 100644 --- a/tests/tests/Report/Html/FacadeTest.php +++ b/tests/tests/Report/Html/FacadeTest.php @@ -64,6 +64,34 @@ public function testProcessRendersFilesInSubdirectoriesAndClassesInNestedNamespa $this->assertStringContainsString('_classes/App/Models/User.html', $fileHtml); } + public function testProcessCanSkipTheClassView(): void + { + $target = TEST_FILES_PATH . 'tmp' . DIRECTORY_SEPARATOR; + $report = $this->buildReportWithNestedNamespacesAndSubdirectory(); + + (new Facade('', null, null, null, false))->process($report, $target); + + $this->assertDirectoryDoesNotExist($target . '_classes'); + + $index = file_get_contents($target . 'index.html'); + + $this->assertNotFalse($index); + $this->assertStringNotContainsString('_classes/', $index); + $this->assertStringNotContainsString('nav-tabs', $index); + + $fileHtml = file_get_contents($target . 'sub/User.php.html'); + + $this->assertNotFalse($fileHtml); + $this->assertStringNotContainsString('_classes/', $fileHtml); + $this->assertStringNotContainsString('nav-tabs', $fileHtml); + + $dashboard = file_get_contents($target . 'dashboard.html'); + + $this->assertNotFalse($dashboard); + $this->assertStringNotContainsString('_classes/', $dashboard); + $this->assertStringNotContainsString('nav-tabs', $dashboard); + } + private function buildReportWithNestedNamespacesAndSubdirectory(): DirectoryNode { $rootPath = TEST_FILES_PATH . 'FacadeNested'; From 23c7a01a387ebe8648aa339c15b5bc9fa9b96788 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 9 Jul 2026 11:11:51 +0200 Subject: [PATCH 12/12] Do not hard-code directory separator in fixture and expectation files for class view end-to-end test to make it pass on Windows --- .../ChildClass.php.html | 2 +- .../ExampleClass.php.html | 2 +- .../ExampleTrait.php.html | 2 +- .../ParentClass.php.html | 2 +- tests/src/CoverageFixtureProvider.php | 17 +++++++++-------- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ChildClass.php.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ChildClass.php.html index df6216f45..685bb2671 100644 --- a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ChildClass.php.html +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ChildClass.php.html @@ -2,7 +2,7 @@ - Code Coverage for %sClassView/ChildClass.php + Code Coverage for %sClassView%eChildClass.php diff --git a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleClass.php.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleClass.php.html index 608f19321..697d5c895 100644 --- a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleClass.php.html +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleClass.php.html @@ -2,7 +2,7 @@ - Code Coverage for %sClassView/ExampleClass.php + Code Coverage for %sClassView%eExampleClass.php diff --git a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleTrait.php.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleTrait.php.html index 0ed1d2c19..4f7c53276 100644 --- a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleTrait.php.html +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleTrait.php.html @@ -2,7 +2,7 @@ - Code Coverage for %sClassView/ExampleTrait.php + Code Coverage for %sClassView%eExampleTrait.php diff --git a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ParentClass.php.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ParentClass.php.html index b69b4e99c..e36c4e37e 100644 --- a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ParentClass.php.html +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ParentClass.php.html @@ -2,7 +2,7 @@ - Code Coverage for %sClassView/ParentClass.php + Code Coverage for %sClassView%eParentClass.php diff --git a/tests/src/CoverageFixtureProvider.php b/tests/src/CoverageFixtureProvider.php index 3efb037a2..c07fea865 100644 --- a/tests/src/CoverageFixtureProvider.php +++ b/tests/src/CoverageFixtureProvider.php @@ -9,6 +9,7 @@ */ namespace SebastianBergmann\CodeCoverage; +use const DIRECTORY_SEPARATOR; use BankAccount; use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; use SebastianBergmann\CodeCoverage\Driver\Driver; @@ -1227,10 +1228,10 @@ public function coverageForClassWithAnonymousFunction(): CodeCoverage public function coverageForClassesWithTraitsAndInheritance(): CodeCoverage { $filter = new Filter; - $filter->includeFile(TEST_FILES_PATH . 'ClassView/ChildClass.php'); - $filter->includeFile(TEST_FILES_PATH . 'ClassView/ExampleClass.php'); - $filter->includeFile(TEST_FILES_PATH . 'ClassView/ExampleTrait.php'); - $filter->includeFile(TEST_FILES_PATH . 'ClassView/ParentClass.php'); + $filter->includeFile(TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ChildClass.php'); + $filter->includeFile(TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ExampleClass.php'); + $filter->includeFile(TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ExampleTrait.php'); + $filter->includeFile(TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ParentClass.php'); $coverage = new CodeCoverage( $this->xdebugFakeForClassesWithTraitsAndInheritance(), @@ -1375,17 +1376,17 @@ private function xdebugFakeForClassesWithTraitsAndInheritance(): Driver return new FakeDriver( RawCodeCoverageData::fromXdebugWithoutPathCoverage( [ - TEST_FILES_PATH . 'ClassView/ChildClass.php' => [ + TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ChildClass.php' => [ 11 => 1, ], - TEST_FILES_PATH . 'ClassView/ExampleClass.php' => [ + TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ExampleClass.php' => [ 9 => 1, 14 => -1, ], - TEST_FILES_PATH . 'ClassView/ExampleTrait.php' => [ + TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ExampleTrait.php' => [ 9 => 1, ], - TEST_FILES_PATH . 'ClassView/ParentClass.php' => [ + TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ParentClass.php' => [ 9 => -1, ], ],