diff --git a/build/scripts/generate-expectations.php b/build/scripts/generate-expectations.php index a55901476..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; @@ -133,33 +134,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); 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..84d3b7b70 --- /dev/null +++ b/src/Report/Html/ClassView/Builder.php @@ -0,0 +1,246 @@ + + * + * 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; + + // 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($visited[$parentName]) || !isset($this->classRegistry[$parentName])) { + break; + } + + $visited[$parentName] = true; + + $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..a2f0c02c9 --- /dev/null +++ b/src/Report/Html/ClassView/Node/NamespaceNode.php @@ -0,0 +1,454 @@ + + * + * 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\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 + */ +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->classesInSubtree() as $class) { + $this->numExecutableLines += $class->class_()->executableLines; + } + + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutableLines += $trait->executableLines; + } + } + + return $this->numExecutableLines; + } + + public function numberOfExecutedLines(): int + { + if ($this->numExecutedLines === -1) { + $this->numExecutedLines = 0; + + foreach ($this->classesInSubtree() as $class) { + $this->numExecutedLines += $class->class_()->executedLines; + } + + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutedLines += $trait->executedLines; + } + } + + return $this->numExecutedLines; + } + + public function numberOfExecutableBranches(): int + { + if ($this->numExecutableBranches === -1) { + $this->numExecutableBranches = 0; + + foreach ($this->classesInSubtree() as $class) { + $this->numExecutableBranches += $class->class_()->executableBranches; + } + + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutableBranches += $trait->executableBranches; + } + } + + return $this->numExecutableBranches; + } + + public function numberOfExecutedBranches(): int + { + if ($this->numExecutedBranches === -1) { + $this->numExecutedBranches = 0; + + foreach ($this->classesInSubtree() as $class) { + $this->numExecutedBranches += $class->class_()->executedBranches; + } + + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutedBranches += $trait->executedBranches; + } + } + + return $this->numExecutedBranches; + } + + public function numberOfExecutablePaths(): int + { + if ($this->numExecutablePaths === -1) { + $this->numExecutablePaths = 0; + + foreach ($this->classesInSubtree() as $class) { + $this->numExecutablePaths += $class->class_()->executablePaths; + } + + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutablePaths += $trait->executablePaths; + } + } + + return $this->numExecutablePaths; + } + + public function numberOfExecutedPaths(): int + { + if ($this->numExecutedPaths === -1) { + $this->numExecutedPaths = 0; + + foreach ($this->classesInSubtree() as $class) { + $this->numExecutedPaths += $class->class_()->executedPaths; + } + + foreach ($this->traitsInSubtree() as $trait) { + $this->numExecutedPaths += $trait->executedPaths; + } + } + + 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->methodsInSubtree() as $method) { + if ($method->executableLines > 0) { + $this->numMethods++; + } + } + } + + return $this->numMethods; + } + + public function numberOfTestedMethods(): int + { + if ($this->numTestedMethods === -1) { + $this->numTestedMethods = 0; + + foreach ($this->methodsInSubtree() 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 + { + 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; + } + } + + /** + * @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; + $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..8e315d026 --- /dev/null +++ b/src/Report/Html/ClassView/Renderer/Class_.php @@ -0,0 +1,394 @@ + + * + * 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 $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 $i => $section) { + foreach ($section->methods as $methodName => $method) { + $items .= $this->renderMethodItem( + $methodItemTemplate, + $method, + ' [' . htmlspecialchars($section->className, self::HTML_SPECIAL_CHARS_FLAGS) . '] ', + 'parent-' . $i . '-', + ); + } + } + + return $items; + } + + private function renderMethodItem(Template $template, ProcessedMethodType $method, string $indent = ' ', string $anchorPrefix = ''): 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, + $anchorPrefix, + $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 $i => $section) { + $sections .= $this->renderSectionHeader('From ' . $section->traitName); + + $sections .= $this->renderSourceSection( + $section->traitName, + $section->filePath, + $section->startLine, + $section->endLine, + $section->fileNode->lineCoverageData(), + $section->fileNode->testData(), + 'trait-' . $i . '-', + ); + } + + // Parent source sections + foreach ($node->parentSections() as $i => $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(), + 'parent-' . $i . '-', + ); + } + } + + 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 $anchorPrefix = ''): 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, $anchorPrefix); + } + + $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..db32c6e96 --- /dev/null +++ b/src/Report/Html/ClassView/Renderer/Dashboard.php @@ -0,0 +1,258 @@ + + * + * 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 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); + + $pathToRoot = $this->pathToRootForNamespace($node); + $bubbleChart = new BubbleChart($this->thresholds); + + $template->setVar( + [ + '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), + ], + ); + + 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 $pathToRoot): 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' => $pathToRoot . $class->link, + ]; + } + + return $items; + } + + /** + * @param array $classes + * + * @return list + */ + private function methodItems(array $classes, string $pathToRoot): 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' => $pathToRoot . $method->link, + ]; + } + } + + return $items; + } + + /** + * @param array $classes + */ + private function classCrapTable(array $classes, string $pathToRoot): string + { + $items = []; + + foreach ($classes as $className => $class) { + $items[] = [ + 'name' => $className, + 'coverage' => $class->coverage, + 'crap' => (new CrapIndex($class->ccn, $class->coverage))->asString(), + 'link' => $pathToRoot . $class->link, + ]; + } + + return $this->crapTable($items, 'Class'); + } + + /** + * @param array $classes + */ + private function methodCrapTable(array $classes, string $pathToRoot): 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' => $pathToRoot . $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..cebc94810 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; @@ -36,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/'; } @@ -53,32 +62,39 @@ 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, - ); + $fileToClassMap = []; - $directory = new Directory( - $this->templatePath, - $this->generator, - $date, - $this->thresholds, - $hasBranchCoverage, - $hasPathCoverage, - ); + if ($this->classView) { + $rootNamespace = new Builder()->build($report); + $fileToClassMap = $this->buildFileToClassMap($rootNamespace); + } - $file = new File( - $this->templatePath, - $this->generator, - $date, - $this->thresholds, - $hasBranchCoverage, - $hasPathCoverage, - ); + $this->renderFileView($report, $target, $date, $hasBranchCoverage, $hasPathCoverage, $fileToClassMap); + + if (isset($rootNamespace)) { + $this->renderClassView($rootNamespace, $target, $date, $hasBranchCoverage, $hasPathCoverage); + } + + $this->copyFiles($target); + $this->renderCss($target); + } + + /** + * @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); + + if (!$this->classView) { + $dashboard->disableClassView(); + $directory->disableClassView(); + $file->disableClassView(); + } $directory->render($report, $target . 'index.html'); $dashboard->render($report, $target . 'dashboard.html'); @@ -101,9 +117,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..5026b88ea 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,12 @@ abstract class Renderer protected bool $hasPathCoverage; protected string $version; + /** + * @var array + */ + private array $fileToClassMap = []; + private bool $classView = true; + public function __construct(string $templatePath, string $generator, string $date, Thresholds $thresholds, bool $hasBranchCoverage, bool $hasPathCoverage) { $this->templatePath = $templatePath; @@ -76,6 +88,20 @@ 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; + } + + public function disableClassView(): void + { + $this->classView = false; } /** @@ -224,11 +250,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 +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->classView ? $this->viewSwitcher($pathToRoot, 'files', 'index.html', $classesTarget) : '', ], ); } @@ -245,6 +279,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 +395,53 @@ 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 $anchorPrefix = ''): string + { + $template->setVar( + [ + 'anchor' => $anchorPrefix . $lineNumber, + '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/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/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 @@ + + + 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..620f50d5d --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForBankAccount/_classes/BankAccount.html @@ -0,0 +1,225 @@ + + + + + Code Coverage for BankAccount + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
+
+ 62.50% covered (warning) +
+
+
62.50%
5 / 8
+
+ 75.00% covered (warning) +
+
+
75.00%
3 / 4
CRAP
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
 getBalance
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
 setBalance
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 3
+
+ 0.00% covered (danger) +
+
+
0.00%
0 / 1
6
 depositMoney
+
+ 100.00% covered (success) +
+
+
100.00%
2 / 2
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
 withdrawMoney
+
+ 100.00% covered (success) +
+
+
100.00%
2 / 2
+
+ 100.00% covered (success) +
+
+
100.00%
1 / 1
1
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2class BankAccount
3{
4    protected $balance = 0;
5
6    public function getBalance()
7    {
8        return $this->balance;
9    }
10
11    protected function setBalance($balance)
12    {
13        if ($balance >= 0) {
14            $this->balance = $balance;
15        } else {
16            throw new RuntimeException;
17        }
18    }
19
20    public function depositMoney($balance)
21    {
22        $this->setBalance($this->getBalance() + $balance);
23
24        return $this->getBalance();
25    }
26
27    public function withdrawMoney($balance)
28    {
29        $this->setBalance($this->getBalance() - $balance);
30
31        return $this->getBalance();
32        return $this->getBalance();
33    }
34}
+ +
+
+

Legend

+

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

+

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

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

Legend

+

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

+

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

+ + + +
+
+ + + + + diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/_classes/dashboard.html new file mode 100644 index 000000000..4faed3260 --- /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..92c18d4c2 --- /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..4876661fe 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/index.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html index ff821b35d..3b972cce9 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ChildClass.php.html b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ChildClass.php.html new file mode 100644 index 000000000..685bb2671 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ChildClass.php.html @@ -0,0 +1,169 @@ + + + + + Code Coverage for %sClassView%eChildClass.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..697d5c895 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleClass.php.html @@ -0,0 +1,194 @@ + + + + + Code Coverage for %sClassView%eExampleClass.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..4f7c53276 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ExampleTrait.php.html @@ -0,0 +1,167 @@ + + + + + Code Coverage for %sClassView%eExampleTrait.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..e36c4e37e --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForClassesWithTraitsAndInheritance/ParentClass.php.html @@ -0,0 +1,167 @@ + + + + + Code Coverage for %sClassView%eParentClass.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..d209e4563 --- /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/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Bar.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Bar.html new file mode 100644 index 000000000..309ea51e2 --- /dev/null +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/_classes/Bar.html @@ -0,0 +1,110 @@ + + + + + Code Coverage for Bar + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Methods
Classes
Total
n/a
0 / 0
n/a
0 / 0
CRAP
n/a
0 / 0
 foo
n/a
0 / 0
n/a
0 / 0
1
+
+ + + + + + + + + + + + + +
18class Bar
19{
20    /**
21     * @codeCoverageIgnore
22     */
23    public function foo()
24    {
25    }
26}
+ +
+
+

Legend

+

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

+

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

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

Legend

+

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

+

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

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

Legend

+

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

+

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

+ + + +
+
+ + + + + diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/_classes/dashboard.html new file mode 100644 index 000000000..6734fbd40 --- /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..e7dd3df4d --- /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..c74109d10 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/index.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html new file mode 100644 index 000000000..ef0f3a776 --- /dev/null +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/Foo.html @@ -0,0 +1,100 @@ + + + + + Code Coverage for Foo + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
Code Coverage
 
Lines
Branches
Paths
Methods
Classes
Total
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
n/a
0 / 0
CRAP
n/a
0 / 0
+
+ + + + + + + +
5class Foo
6{
7}
+ +
+
+

Legend

+

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

+

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

+ + + +
+
+ + + + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/_classes/dashboard.html new file mode 100644 index 000000000..714624407 --- /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..40b091e67 --- /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..701024ee8 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/index.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html index 1446f796d..ae376b40c 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html index 590d2010b..f470fe77e 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html @@ -21,6 +21,11 @@ + + diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html index 640b60b1f..37745afb8 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html @@ -21,6 +21,11 @@ + + diff --git a/tests/src/CoverageFixtureProvider.php b/tests/src/CoverageFixtureProvider.php index 00fc7a9ae..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; @@ -1224,6 +1225,25 @@ public function coverageForClassWithAnonymousFunction(): CodeCoverage return $coverage; } + public function coverageForClassesWithTraitsAndInheritance(): CodeCoverage + { + $filter = new Filter; + $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(), + $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 +1371,29 @@ private function xdebugFakeForFileWithIgnoredLines(): Driver ); } + private function xdebugFakeForClassesWithTraitsAndInheritance(): Driver + { + return new FakeDriver( + RawCodeCoverageData::fromXdebugWithoutPathCoverage( + [ + TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ChildClass.php' => [ + 11 => 1, + ], + TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ExampleClass.php' => [ + 9 => 1, + 14 => -1, + ], + TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . 'ExampleTrait.php' => [ + 9 => 1, + ], + TEST_FILES_PATH . 'ClassView' . DIRECTORY_SEPARATOR . '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/Node/FileTest.php b/tests/tests/Node/FileTest.php index ba8f63e4b..e57ab2f1a 100644 --- a/tests/tests/Node/FileTest.php +++ b/tests/tests/Node/FileTest.php @@ -397,6 +397,68 @@ 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()); + } + + 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'); diff --git a/tests/tests/Report/Html/ClassView/BuilderTest.php b/tests/tests/Report/Html/ClassView/BuilderTest.php new file mode 100644 index 000000000..ff064a37d --- /dev/null +++ b/tests/tests/Report/Html/ClassView/BuilderTest.php @@ -0,0 +1,371 @@ + + * + * 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 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'); + $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..922fe1811 --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Node/NamespaceNodeTest.php @@ -0,0 +1,432 @@ + + * + * 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\Data\ProcessedTraitType; +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 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', ''); + $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 + * @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', array $traitSections = [], array $parentSections = []): ClassNode + { + 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, ''); + + return new ClassNode($className, 'App', '/path/to/test.php', 1, 20, $processedClass, $this->createFileNode(), $traitSections, $parentSections, $parent); + } + + private function createFileNode(): File + { + return new File('test.php', new Directory('root'), 'abc123', [], [], [], [], [], [], new LinesOfCode(0, 0, 0)); + } +} 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..6ae292aba --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Renderer/Class_Test.php @@ -0,0 +1,408 @@ + + * + * 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 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; +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); + + // 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 + { + $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..96feaef85 --- /dev/null +++ b/tests/tests/Report/Html/ClassView/Renderer/DashboardTest.php @@ -0,0 +1,170 @@ + + * + * 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); + + // 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 + { + $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); + + // Links point to file-view pages, which live above _classes/App/Models/ + $this->assertStringContainsString('href="../../../t.php.html#1"', $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, + 't.php.html#1', + ); + $processedClass = new ProcessedClassType( + $className, + '', + ['doSomething' => $method], + 1, + 3, + 3, + 0, + 0, + 0, + 0, + 1, + 100, + 1, + 't.php.html#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..fef6c3bbc 100644 --- a/tests/tests/Report/Html/EndToEndTest.php +++ b/tests/tests/Report/Html/EndToEndTest.php @@ -10,15 +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 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; @@ -124,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 === '\\') { @@ -159,35 +183,85 @@ public function testHtmlSpecialCharactersInFileAndDirectoryNamesAreEncoded(): vo private function assertFilesEquals(string $expectedFilesPath, string $actualFilesPath): void { - $expectedFilesIterator = new FilesystemIterator($expectedFilesPath); - $actualFilesIterator = new RegexIterator(new FilesystemIterator($actualFilesPath), '/.html/'); + $expectedFiles = $this->htmlFiles($expectedFilesPath); + $actualFiles = $this->htmlFiles($actualFilesPath); $this->assertSame( - iterator_count($expectedFilesIterator), - iterator_count($actualFilesIterator), - 'Generated files and expected files not match', + array_keys($expectedFiles), + array_keys($actualFiles), + 'Generated files and expected files do not match', ); - foreach ($expectedFilesIterator as $fileInfo) { - if (!$fileInfo instanceof SplFileInfo) { - continue; // @codeCoverageIgnore - } - - $filename = $fileInfo->getFilename(); - - $actualFile = $actualFilesPath . DIRECTORY_SEPARATOR . $filename; - - $this->assertFileExists($actualFile); - - $actual = file_get_contents($actualFile); + foreach ($expectedFiles as $relativePath => $expectedFile) { + $actual = file_get_contents($actualFilesPath . DIRECTORY_SEPARATOR . $relativePath); $this->assertNotFalse($actual); $this->assertStringMatchesFormatFile( - $fileInfo->getPathname(), + $expectedFile, str_replace(PHP_EOL, "\n", $actual), - "{$filename} not match", + "{$relativePath} does not match", ); } + + $this->assertRelativeLinkTargetsExist($actualFilesPath); + } + + private function assertRelativeLinkTargetsExist(string $reportPath): void + { + foreach ($this->htmlFiles($reportPath) as $relativePath => $absolutePath) { + $html = file_get_contents($absolutePath); + + $this->assertNotFalse($html); + + preg_match_all('/(?:href|src)="([^"]+)"/', $html, $matches); + + foreach ($matches[1] as $target) { + $target = html_entity_decode($target, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + if (str_starts_with($target, '#') || str_contains($target, '://')) { + continue; + } + + $target = explode('#', $target, 2)[0]; + $target = explode('?', $target, 2)[0]; + + if ($target === '') { + continue; + } + + $this->assertFileExists( + dirname($reportPath . DIRECTORY_SEPARATOR . $relativePath) . DIRECTORY_SEPARATOR . $target, + sprintf('%s links to %s which does not exist', $relativePath, $target), + ); + } + } + } + + /** + * @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; } } diff --git a/tests/tests/Report/Html/FacadeTest.php b/tests/tests/Report/Html/FacadeTest.php new file mode 100644 index 000000000..11b8fae6f --- /dev/null +++ b/tests/tests/Report/Html/FacadeTest.php @@ -0,0 +1,221 @@ + + * + * 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); + } + + 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'; + $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), + ); + } +}