From 0a9bd533aba53cda2320f039c4d7adf0c73993cf Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sat, 14 Mar 2026 08:31:44 +0100 Subject: [PATCH 1/5] Implement tab-based navigation between line coverage, branch coverage, and path coverage --- src/Report/Html/Renderer/File.php | 36 +++++++++++++++++++ .../Renderer/Template/file_branch.html.dist | 1 + .../BankAccount.php.html | 5 +++ .../BankAccount.php_branch.html | 5 +++ .../BankAccount.php_path.html | 5 +++ .../source_without_namespace.php.html | 5 +++ .../source_without_namespace.php_branch.html | 5 +++ .../source_without_namespace.php_path.html | 5 +++ 8 files changed, 67 insertions(+) diff --git a/src/Report/Html/Renderer/File.php b/src/Report/Html/Renderer/File.php index 6dbbc5763..be9433dfc 100644 --- a/src/Report/Html/Renderer/File.php +++ b/src/Report/Html/Renderer/File.php @@ -218,6 +218,12 @@ public function render(FileNode $node, string $file): void ], ); + if ($this->hasBranchCoverage) { + $template->setVar( + ['tabs' => $this->renderViewTabs($node->name(), 'line')], + ); + } + try { $template->renderTo($file . '.html'); } catch (Exception $e) { @@ -231,6 +237,7 @@ public function render(FileNode $node, string $file): void if ($this->hasBranchCoverage) { $template->setVar( [ + 'tabs' => $this->renderViewTabs($node->name(), 'branch'), 'items' => $this->renderItems($node), 'lines' => $this->renderSourceWithBranchCoverage($node), 'legend' => '

Fully coveredPartially coveredNot covered

', @@ -250,6 +257,7 @@ public function render(FileNode $node, string $file): void $template->setVar( [ + 'tabs' => $this->renderViewTabs($node->name(), 'path'), 'items' => $this->renderItems($node), 'lines' => $this->renderSourceWithPathCoverage($node), 'legend' => '

Fully coveredPartially coveredNot covered

', @@ -589,6 +597,34 @@ private function renderSourceWithLineCoverage(FileNode $node): string return $linesTemplate->render(); } + private function renderViewTabs(string $fileName, string $activeView): string + { + $tabs = [ + 'line' => ['href' => $fileName . '.html', 'label' => 'Line Coverage'], + 'branch' => ['href' => $fileName . '_branch.html', 'label' => 'Branch Coverage'], + 'path' => ['href' => $fileName . '_path.html', 'label' => 'Path Coverage'], + ]; + + $html = ' '; + + return $html; + } + private function renderSourceWithBranchCoverage(FileNode $node): string { $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); diff --git a/src/Report/Html/Renderer/Template/file_branch.html.dist b/src/Report/Html/Renderer/Template/file_branch.html.dist index b8bcf3747..53cacc6c3 100644 --- a/src/Report/Html/Renderer/Template/file_branch.html.dist +++ b/src/Report/Html/Renderer/Template/file_branch.html.dist @@ -24,6 +24,7 @@
+{{tabs}}
diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html index 681941ba2..9d4f6d523 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html @@ -26,6 +26,11 @@
diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html index 681941ba2..a2914f57c 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html @@ -26,6 +26,11 @@
diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html index 681941ba2..6204f8417 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html @@ -26,6 +26,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..13ddbe88d 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html @@ -26,6 +26,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..5c43276f3 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 @@ -26,6 +26,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..ff4d5e3cb 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 @@ -26,6 +26,11 @@
From 7012585225a1547e7f5ce1227bbac7b8b7cb0cb9 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sat, 14 Mar 2026 09:05:30 +0100 Subject: [PATCH 2/5] Experiment with branch/path summary table per method --- src/Report/Html/Renderer/File.php | 229 +++++++----------- .../Html/Renderer/Template/css/style.css | 13 + src/Report/Html/Renderer/Template/js/file.js | 41 +++- .../source_without_namespace.php.html | 2 +- .../source_without_namespace.php_branch.html | 52 ++-- .../source_without_namespace.php_path.html | 51 ++-- 6 files changed, 165 insertions(+), 223 deletions(-) diff --git a/src/Report/Html/Renderer/File.php b/src/Report/Html/Renderer/File.php index be9433dfc..f953de81b 100644 --- a/src/Report/Html/Renderer/File.php +++ b/src/Report/Html/Renderer/File.php @@ -93,10 +93,12 @@ use function explode; use function file_get_contents; use function htmlspecialchars; +use function implode; use function is_string; use function ksort; +use function max; +use function min; use function range; -use function sort; use function sprintf; use function str_ends_with; use function str_replace; @@ -810,99 +812,75 @@ private function renderBranchStructure(FileNode $node): string $coverageData = $node->functionCoverageData(); $testData = $node->testData(); - $codeLines = $this->loadFile($node->pathAsString()); $branches = ''; ksort($coverageData); /** @var ProcessedFunctionCoverageData $methodData */ foreach ($coverageData as $methodName => $methodData) { - $branchStructure = ''; - - /** @var ProcessedBranchCoverageData $branch */ - foreach ($methodData->branches as $branch) { - $branchStructure .= $this->renderBranchLines($branch, $codeLines, $testData); - } - - if ($branchStructure !== '') { // don't show empty branches - $branches .= '
' . $this->abbreviateMethodName($methodName) . '
' . "\n"; - $branches .= $branchStructure; - } - } - - $branchesTemplate->setVar(['branches' => $branches]); - - return $branchesTemplate->render(); - } - - /** - * @param list $codeLines - */ - private function renderBranchLines(ProcessedBranchCoverageData $branch, array $codeLines, array $testData): string - { - $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); - $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}'); - - $lines = ''; - - $branchLines = range($branch->line_start, $branch->line_end); - sort($branchLines); // sometimes end_line < start_line - - /** @var int $line */ - foreach ($branchLines as $line) { - if (!isset($codeLines[$line])) { // blank line at end of file is sometimes included here + if ($methodData->branches === []) { continue; } - $popoverContent = ''; - $popoverTitle = ''; + $branches .= '
' . $this->abbreviateMethodName($methodName) . '
' . "\n"; + $branches .= '
' . "\n"; + $branches .= '' . "\n"; + $branches .= '' . "\n"; - $numTests = count($branch->hit); + $branchIndex = 1; - if ($numTests === 0) { - $trClass = 'danger'; - } else { - $lineCss = 'covered-by-large-tests'; - $popoverContent = '
    '; + /** @var ProcessedBranchCoverageData $branch */ + foreach ($methodData->branches as $branch) { + $lineStart = min($branch->line_start, $branch->line_end); + $lineEnd = max($branch->line_start, $branch->line_end); + $linesLabel = $lineStart === $lineEnd + ? sprintf('L%d', $lineStart, $lineStart) + : sprintf('L%dL%d', $lineStart, $lineStart, $lineEnd, $lineEnd); - if ($numTests > 1) { - $popoverTitle = $numTests . ' tests cover this branch'; + $numTests = count($branch->hit); + + if ($numTests === 0) { + $statusClass = 'danger'; + $statusLabel = 'Not covered'; + $testsLabel = '—'; } else { - $popoverTitle = '1 test covers this branch'; - } + $statusClass = 'success'; + $statusLabel = 'Covered'; + + $popoverContent = '
      '; - foreach ($branch->hit as $test) { - if ($lineCss === 'covered-by-large-tests' && $testData[$test]['size'] === 'medium') { - $lineCss = 'covered-by-medium-tests'; - } elseif ($testData[$test]['size'] === 'small') { - $lineCss = 'covered-by-small-tests'; + foreach ($branch->hit as $test) { + $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); } - $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); - } - $trClass = $lineCss . ' popin'; - } + $popoverContent .= '
    '; - $popover = ''; + $testsLabel = sprintf( + '%s', + $numTests === 1 ? '1 test' : $numTests . ' tests', + htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS), + $numTests === 1 ? '1 test' : $numTests . ' tests', + ); + } - 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), + $branches .= sprintf( + '
' . "\n", + $statusClass, + $branchIndex, + $linesLabel, + $statusLabel, + $testsLabel, ); - } - $lines .= $this->renderLine($singleLineTemplate, $line, $codeLines[$line - 1], $trClass, $popover); - } + $branchIndex++; + } - if ($lines === '') { - return ''; + $branches .= '
#LinesStatusTests
%d%s%s%s
' . "\n"; } - $linesTemplate->setVar(['lines' => $lines]); + $branchesTemplate->setVar(['branches' => $branches]); - return $linesTemplate->render(); + return $branchesTemplate->render(); } private function renderPathStructure(FileNode $node): string @@ -911,115 +889,86 @@ private function renderPathStructure(FileNode $node): string $coverageData = $node->functionCoverageData(); $testData = $node->testData(); - $codeLines = $this->loadFile($node->pathAsString()); $paths = ''; ksort($coverageData); /** @var ProcessedFunctionCoverageData $methodData */ foreach ($coverageData as $methodName => $methodData) { - $pathStructure = ''; - - if (count($methodData->paths) > 100) { - $pathStructure .= '

' . count($methodData->paths) . ' is too many paths to sensibly render, consider refactoring your code to bring this number down.

'; - + if ($methodData->paths === []) { continue; } - foreach ($methodData->paths as $path) { - $pathStructure .= $this->renderPathLines($path, $methodData->branches, $codeLines, $testData); - } - - if ($pathStructure !== '') { + if (count($methodData->paths) > 100) { $paths .= '
' . $this->abbreviateMethodName($methodName) . '
' . "\n"; - $paths .= $pathStructure; - } - } + $paths .= '

' . count($methodData->paths) . ' is too many paths to sensibly render, consider refactoring your code to bring this number down.

'; - $pathsTemplate->setVar(['paths' => $paths]); + continue; + } - return $pathsTemplate->render(); - } + $paths .= '
' . $this->abbreviateMethodName($methodName) . '
' . "\n"; + $paths .= '' . "\n"; + $paths .= '' . "\n"; + $paths .= '' . "\n"; - /** - * @param array $branches - * @param list $codeLines - */ - private function renderPathLines(ProcessedPathCoverageData $path, array $branches, array $codeLines, array $testData): string - { - $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); - $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}'); - - $lines = ''; - $first = true; + $pathIndex = 1; - foreach ($path->path as $branchId) { - if ($first) { - $first = false; - } else { - $lines .= ' ' . "\n"; - } + foreach ($methodData->paths as $path) { + $branchLabels = []; - $branchLines = range($branches[$branchId]->line_start, $branches[$branchId]->line_end); - sort($branchLines); // sometimes end_line < start_line + foreach ($path->path as $branchId) { + $branch = $methodData->branches[$branchId]; + $branchLine = min($branch->line_start, $branch->line_end); - /** @var int $line */ - foreach ($branchLines as $line) { - if (!isset($codeLines[$line])) { // blank line at end of file is sometimes included here - continue; + $branchLabels[] = sprintf('L%d', $branchLine, $branchLine); } - $popoverContent = ''; - $popoverTitle = ''; + $branchesLabel = implode(' → ', $branchLabels); $numTests = count($path->hit); if ($numTests === 0) { - $trClass = 'danger'; + $statusClass = 'danger'; + $statusLabel = 'Not covered'; + $testsLabel = '—'; } else { - $lineCss = 'covered-by-large-tests'; - $popoverContent = '
    '; + $statusClass = 'success'; + $statusLabel = 'Covered'; - if ($numTests > 1) { - $popoverTitle = $numTests . ' tests cover this path'; - } else { - $popoverTitle = '1 test covers this path'; - } + $popoverContent = '
      '; foreach ($path->hit as $test) { - if ($lineCss === 'covered-by-large-tests' && $testData[$test]['size'] === 'medium') { - $lineCss = 'covered-by-medium-tests'; - } elseif ($testData[$test]['size'] === 'small') { - $lineCss = 'covered-by-small-tests'; - } - $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); } - $trClass = $lineCss . ' popin'; - } - - $popover = ''; + $popoverContent .= '
    '; - if ($popoverTitle !== '') { - $popover = sprintf( - ' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"', - $popoverTitle, + $testsLabel = sprintf( + '%s', + $numTests === 1 ? '1 test' : $numTests . ' tests', htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS), + $numTests === 1 ? '1 test' : $numTests . ' tests', ); } - $lines .= $this->renderLine($singleLineTemplate, $line, $codeLines[$line - 1], $trClass, $popover); + $paths .= sprintf( + '
' . "\n", + $statusClass, + $pathIndex, + $branchesLabel, + $statusLabel, + $testsLabel, + ); + + $pathIndex++; } - } - if ($lines === '') { - return ''; + $paths .= '
#BranchesStatusTests
 
%d%s%s%s
' . "\n"; } - $linesTemplate->setVar(['lines' => $lines]); + $pathsTemplate->setVar(['paths' => $paths]); - return $linesTemplate->render(); + return $pathsTemplate->render(); } private function renderLine(Template $template, int $lineNumber, string $lineContent, string $class, string $popover): string diff --git a/src/Report/Html/Renderer/Template/css/style.css b/src/Report/Html/Renderer/Template/css/style.css index db6d6e264..8d9c68f1f 100644 --- a/src/Report/Html/Renderer/Template/css/style.css +++ b/src/Report/Html/Renderer/Template/css/style.css @@ -224,6 +224,19 @@ table + .structure-heading { padding-top: 0.5em; } +.structure-table { + width: auto; +} + +.structure-table td, .structure-table th { + white-space: nowrap; +} + +.structure-table .popin { + cursor: pointer; + text-decoration: underline dotted; +} + table#code td:first-of-type { padding-left: .75em; padding-right: .75em; diff --git a/src/Report/Html/Renderer/Template/js/file.js b/src/Report/Html/Renderer/Template/js/file.js index 124a8a18f..8eb90e06d 100644 --- a/src/Report/Html/Renderer/Template/js/file.js +++ b/src/Report/Html/Renderer/Template/js/file.js @@ -17,37 +17,52 @@ $(function () { } }); - var $popovers = $('.popin > :first-child'); - $('.popin').on({ + var $linePopovers = $('tr.popin > :first-child'); + var $spanPopovers = $('span.popin[data-bs-content]'); + var $allPopovers = $linePopovers.add($spanPopovers); + + function hideAllExcept($except) { + $allPopovers.each(function () { + var $current = $(this); + if (!$except || !$current.is($except)) { + $current.popover('hide'); + } + }); + } + + $('tr.popin').on({ 'click.popover': function (event) { event.stopPropagation(); var $container = $(this).children().first(); - //Close all other popovers: - $popovers.each(function () { - var $current = $(this); - if (!$current.is($container)) { - $current.popover('hide'); - } - }); - - // Toggle this popover: + hideAllExcept($container); $container.popover('toggle'); }, }); + $spanPopovers.on({ + 'click.popover': function (event) { + event.stopPropagation(); + + var $span = $(this); + + hideAllExcept($span); + $span.popover('toggle'); + }, + }); + //Hide all popovers on outside click: $(document).click(function (event) { if ($(event.target).closest($('.popover')).length === 0) { - $popovers.popover('hide'); + hideAllExcept(null); } }); //Hide all popovers on escape: $(document).keyup(function (event) { if (event.key === 'Escape') { - $popovers.popover('hide'); + hideAllExcept(null); } }); }); 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 13ddbe88d..5e68504fb 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html @@ -180,7 +180,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. + Generated by php-code-coverage %s using %s at %s

diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html index 5c43276f3..b21c0f1c0 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 @@ -183,42 +183,26 @@

Branches

always has an else as part of its logical flow even if you didn't write one.

foo
- - - - - - - - -
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
- - - - - -
15    $a   = true ? true : false;
- - - - - -
15    $a   = true ? true : false;
- +
+ - - - - - -
#LinesStatusTests
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
+1L12L15Not covered— +2L15Not covered— +3L15Not covered— +4L15L18Not covered— +
{closure:%ssource_without_namespace.php:14-14}
- +
+ - - - -
#LinesStatusTests
14    $baz = function () {};
+1L14Not covered— + +
{main}
+ + + + +
#LinesStatusTests
1L19Covered1 test
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1<?php
2class BankAccount
3{
4    protected $balance = 0;
5
6    public function getBalance()
7    {
8        return $this->balance;
9    }
10
11    protected function setBalance($balance)
12    {
13        if ($balance >= 0) {
14            $this->balance = $balance;
15        } else {
16            throw new RuntimeException;
17        }
18    }
19
20    public function depositMoney($balance)
21    {
22        $this->setBalance($this->getBalance() + $balance);
23
24        return $this->getBalance();
25    }
26
27    public function withdrawMoney($balance)
28    {
29        $this->setBalance($this->getBalance() - $balance);
30
31        return $this->getBalance();
32        return $this->getBalance();
33    }
34}
1<?php
2class BankAccount
3{
4    protected $balance = 0;
5
6    public function getBalance()
7    {
8        return $this->balance;
9    }
10
11    protected function setBalance($balance)
12    {
13        if ($balance >= 0) {
14            $this->balance = $balance;
15        } else {
16            throw new RuntimeException;
17        }
18    }
19
20    public function depositMoney($balance)
21    {
22        $this->setBalance($this->getBalance() + $balance);
23
24        return $this->getBalance();
25    }
26
27    public function withdrawMoney($balance)
28    {
29        $this->setBalance($this->getBalance() - $balance);
30
31        return $this->getBalance();
32        return $this->getBalance();
33    }
34}
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..c2c438101 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 @@ -127,25 +127,25 @@
- - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +
1<?php
2
3class CoveredClassWithAnonymousFunctionInStaticMethod
4{
5    public static function runAnonymous()
6    {
7        $filter = ['abc124', 'abc123', '123'];
8
9        array_walk(
10            $filter,
11            function (&$val, $key) {
12                $val = preg_replace('|[^0-9]|', '', $val);
13            }
14        );
15
16        // Should be covered
17        $extravar = true;
18    }
19}
1<?php
2
3class CoveredClassWithAnonymousFunctionInStaticMethod
4{
5    public static function runAnonymous()
6    {
7        $filter = ['abc124', 'abc123', '123'];
8
9        array_walk(
10            $filter,
11            function (&$val, $key) {
12                $val = preg_replace('|[^0-9]|', '', $val);
13            }
14        );
15
16        // Should be covered
17        $extravar = true;
18    }
19}
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..60e613c47 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html @@ -135,48 +135,48 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1<?php
2if ($neverHappens) {
3    // @codeCoverageIgnoreStart
4    print '*';
5    // @codeCoverageIgnoreEnd
6}
7
8/**
9 * @codeCoverageIgnore
10 */
11class Foo
12{
13    public function bar()
14    {
15    }
16}
17
18class Bar
19{
20    /**
21     * @codeCoverageIgnore
22     */
23    public function foo()
24    {
25    }
26}
27
28function baz()
29{
30    print '*'; // @codeCoverageIgnore
31}
32
33interface Bor
34{
35    public function foo();
36}
37
38// @codeCoverageIgnoreStart
39print '
40Multiline
41';
42// @codeCoverageIgnoreEnd
1<?php
2if ($neverHappens) {
3    // @codeCoverageIgnoreStart
4    print '*';
5    // @codeCoverageIgnoreEnd
6}
7
8/**
9 * @codeCoverageIgnore
10 */
11class Foo
12{
13    public function bar()
14    {
15    }
16}
17
18class Bar
19{
20    /**
21     * @codeCoverageIgnore
22     */
23    public function foo()
24    {
25    }
26}
27
28function baz()
29{
30    print '*'; // @codeCoverageIgnore
31}
32
33interface Bor
34{
35    public function foo();
36}
37
38// @codeCoverageIgnoreStart
39print '
40Multiline
41';
42// @codeCoverageIgnoreEnd
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 5e68504fb..bcb5ce066 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html @@ -152,24 +152,24 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
@@ -180,7 +180,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 + Generated by php-code-coverage %s using %s at %s.

diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html index b21c0f1c0..1f694753a 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 @@ -152,24 +152,24 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
150/3    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
@@ -191,7 +191,7 @@
foo
3L15Not covered— 4L15L18Not covered— -
{closure:%ssource_without_namespace.php:14-14}
+
{closure:%se.php:14-14}
@@ -210,7 +210,7 @@
{main}

Legend

Fully coveredPartially coveredNot covered

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

diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html index 5af031b2f..ee16cc151 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 @@ -152,24 +152,24 @@
#LinesStatusTests
- - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
1<?php
2/**
3 * Represents foo.
4 */
5class Foo
6{
7}
8
9/**
10 * @param mixed $bar
11 */
12function &foo($bar)
13{
14    $baz = function () {};
15    $a   = true ? true : false;
16    $b   = "{$a}";
17    $c   = "${b}";
18}
@@ -189,7 +189,7 @@
foo
1L12L15L15Not covered— 2L12L15L15Not covered— -
{closure:%ssource_without_namespace.php:14-14}
+
{closure:%se.php:14-14}
@@ -208,7 +208,7 @@
{main}

Legend

Fully coveredPartially coveredNot covered

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

From 7c66467ff02a4f12072cacedd02b99420b0015d8 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sat, 14 Mar 2026 10:27:27 +0100 Subject: [PATCH 4/5] Even more experimentation with how branch and path coverage is presented --- src/Report/Html/Renderer/File.php | 84 ++++++++++++++----- .../Html/Renderer/Template/css/style.css | 10 +++ .../source_without_namespace.php_branch.html | 8 +- .../source_without_namespace.php_path.html | 6 +- 4 files changed, 82 insertions(+), 26 deletions(-) diff --git a/src/Report/Html/Renderer/File.php b/src/Report/Html/Renderer/File.php index 54f5da738..3f00568b5 100644 --- a/src/Report/Html/Renderer/File.php +++ b/src/Report/Html/Renderer/File.php @@ -636,8 +636,8 @@ private function renderSourceWithBranchCoverage(FileNode $node): string $testData = $node->testData(); $codeLines = $this->loadFile($node->pathAsString()); - $lineData = []; - $branchStartData = []; + $lineData = []; + $decisionPointData = []; foreach (array_keys($codeLines) as $line) { $lineData[$line + 1] = [ @@ -650,18 +650,22 @@ private function renderSourceWithBranchCoverage(FileNode $node): string /** @var ProcessedFunctionCoverageData $method */ foreach ($functionCoverageData as $method) { /** @var ProcessedBranchCoverageData $branch */ - foreach ($method->branches as $branch) { - $startLine = min($branch->line_start, $branch->line_end); + foreach ($method->branches as $branchId => $branch) { + if (count($branch->out) > 1) { + $decisionLine = max($branch->line_start, $branch->line_end); - if (isset($lineData[$startLine])) { - if (!isset($branchStartData[$startLine])) { - $branchStartData[$startLine] = ['total' => 0, 'hit' => 0]; - } + if (isset($lineData[$decisionLine]) && !isset($decisionPointData[$decisionLine])) { + $targets = []; - $branchStartData[$startLine]['total']++; + foreach ($branch->out as $targetBranchId) { + if (isset($method->branches[$targetBranchId])) { + $targets[] = $method->branches[$targetBranchId]->hit !== []; + } + } - if ($branch->hit !== []) { - $branchStartData[$startLine]['hit']++; + if (count($targets) > 1) { + $decisionPointData[$decisionLine] = $targets; + } } } @@ -700,8 +704,16 @@ private function renderSourceWithBranchCoverage(FileNode $node): string $lineCss = 'warning'; } - if (isset($branchStartData[$i]) && $branchStartData[$i]['total'] > 1) { - $coverageCount = $branchStartData[$i]['hit'] . '/' . $branchStartData[$i]['total']; + if (isset($decisionPointData[$i])) { + $markers = ''; + + foreach ($decisionPointData[$i] as $isHit) { + $markers .= $isHit + ? '' + : ''; + } + + $coverageCount = $markers; } $popoverContent = '
#BranchesStatusTests
' . "\n"; $branches .= '' . "\n"; $branches .= '' . "\n"; @@ -923,14 +951,28 @@ private function renderPathStructure(FileNode $node): string continue; } - if (count($methodData->paths) > 100) { - $paths .= '
' . $this->abbreviateMethodName($methodName) . '
' . "\n"; - $paths .= '

' . count($methodData->paths) . ' is too many paths to sensibly render, consider refactoring your code to bring this number down.

'; + $pathCount = count($methodData->paths); + $hitPathCount = 0; - continue; + foreach ($methodData->paths as $path) { + if ($path->hit !== []) { + $hitPathCount++; + } + } + + $badge = sprintf( + ' %d/%d', + $hitPathCount === $pathCount ? 'success' : ($hitPathCount === 0 ? 'danger' : 'warning'), + $hitPathCount, + $pathCount, + ); + + $paths .= '
' . $this->abbreviateMethodName($methodName) . '' . $badge . '
' . "\n"; + + if ($pathCount > 100) { + $paths .= '
' . $pathCount . ' paths — click to expand' . "\n"; } - $paths .= '
' . $this->abbreviateMethodName($methodName) . '
' . "\n"; $paths .= '
#LinesStatusTests
' . "\n"; $paths .= '' . "\n"; $paths .= '' . "\n"; @@ -988,6 +1030,10 @@ private function renderPathStructure(FileNode $node): string } $paths .= '
#BranchesStatusTests
' . "\n"; + + if ($pathCount > 100) { + $paths .= '' . "\n"; + } } $pathsTemplate->setVar(['paths' => $paths]); diff --git a/src/Report/Html/Renderer/Template/css/style.css b/src/Report/Html/Renderer/Template/css/style.css index e3669d0bc..60bb306ab 100644 --- a/src/Report/Html/Renderer/Template/css/style.css +++ b/src/Report/Html/Renderer/Template/css/style.css @@ -185,6 +185,16 @@ td.col-0 { display: none; } +.branch-hit { + color: var(--phpunit-success-bar); + font-weight: bold; +} + +.branch-miss { + color: var(--phpunit-danger-bar); + font-weight: bold; +} + td span.comment { color: var(--bs-secondary-color); } 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 1f694753a..80d94a9ce 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 @@ -166,7 +166,7 @@ 12function &foo($bar) 13{ 14    $baz = function () {}; - 150/3    $a   = true ? true : false; + 15    $a   = true ? true : false; 16    $b   = "{$a}"; 17    $c   = "${b}"; 18} @@ -182,7 +182,7 @@

Branches

Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

-
foo
+
foo 0/4
@@ -191,13 +191,13 @@
foo
#LinesStatusTests
3L15Not covered
4L15L18Not covered
-
{closure:%se.php:14-14}
+
{closure:%se.php:14-14} 0/1
#LinesStatusTests
1L14Not covered
-
{main}
+
{main} 1/1
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 ee16cc151..4ea5b7e92 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 @@ -182,20 +182,20 @@

Paths

Please also be aware that some paths may include implicit rather than explicit branches, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

-
foo
+
foo 0/2
#LinesStatusTests
#BranchesStatusTests
1L12L15L15Not covered
2L12L15L15Not covered
-
{closure:%se.php:14-14}
+
{closure:%se.php:14-14} 0/1
#BranchesStatusTests
1L14Not covered
-
{main}
+
{main} 1/1
From 59f5c88a7e181164d4de892dc56d9a006ddbd65f Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Wed, 25 Mar 2026 08:57:31 +0100 Subject: [PATCH 5/5] Initial work on visualizing path coverage --- src/Report/Html/ControlFlowGraph.php | 263 ++++++++++++++++++ src/Report/Html/Facade.php | 1 + src/Report/Html/Renderer/File.php | 58 +++- .../Html/Renderer/Template/css/style.css | 37 +++ src/Report/Html/Renderer/Template/js/file.js | 32 +++ .../source_without_namespace.php_path.html | 11 +- 6 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 src/Report/Html/ControlFlowGraph.php diff --git a/src/Report/Html/ControlFlowGraph.php b/src/Report/Html/ControlFlowGraph.php new file mode 100644 index 000000000..b8b7170b6 --- /dev/null +++ b/src/Report/Html/ControlFlowGraph.php @@ -0,0 +1,263 @@ + + * + * 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 function count; +use function fclose; +use function fwrite; +use function implode; +use function max; +use function min; +use function preg_replace; +use function proc_close; +use function proc_open; +use function sprintf; +use function stream_get_contents; +use function stream_set_blocking; +use function strlen; +use function substr; +use SebastianBergmann\CodeCoverage\Data\ProcessedFunctionCoverageData; +use SebastianBergmann\CodeCoverage\Data\ProcessedPathCoverageData; + +/** + * @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 ControlFlowGraph +{ + public const int XDEBUG_EXIT_BRANCH = 2147483645; + private ?bool $dotAvailable = null; + private readonly Colors $colors; + + public function __construct(Colors $colors) + { + $this->colors = $colors; + } + + /** + * @param null|array $paths + */ + public function renderSvg(ProcessedFunctionCoverageData $methodData, ?array $paths = null): string + { + $dot = $this->generateDot($methodData, $paths); + + return $this->dotToSvg($dot); + } + + /** + * @param null|array $paths + */ + private function generateDot(ProcessedFunctionCoverageData $methodData, ?array $paths = null): string + { + $coveredFill = $this->colors->successLow(); + $coveredBorder = $this->colors->successBar(); + $uncoveredFill = $this->colors->danger(); + $uncoveredEdge = $this->colors->dangerBar(); + + $dot = "digraph {\n"; + $dot .= " rankdir=TB;\n"; + $dot .= ' node [shape=box, style=filled, fontname="sans-serif", fontsize=11];' . "\n"; + $dot .= ' entry [label="entry", shape=oval, style=filled, fillcolor="#f5f5f5", color="#999999"];' . "\n"; + + $hasExit = false; + $firstBranchId = null; + + foreach ($methodData->branches as $branchId => $branch) { + if ($firstBranchId === null) { + $firstBranchId = $branchId; + } + + foreach ($branch->out as $destBranchId) { + if ($destBranchId === self::XDEBUG_EXIT_BRANCH) { + $hasExit = true; + } + } + + $lineStart = min($branch->line_start, $branch->line_end); + $lineEnd = max($branch->line_start, $branch->line_end); + $label = $lineStart === $lineEnd + ? sprintf('L%d', $lineStart) + : sprintf('L%d-L%d', $lineStart, $lineEnd); + + if ($branch->hit !== []) { + $fillColor = $coveredFill; + $color = $coveredBorder; + } else { + $fillColor = $uncoveredFill; + $color = $uncoveredEdge; + } + + $dot .= sprintf( + ' b%d [label="%s", fillcolor="%s", color="%s"];' . "\n", + $branchId, + $label, + $fillColor, + $color, + ); + } + + if ($hasExit) { + $dot .= ' exit [label="exit", shape=oval, style=filled, fillcolor="#f5f5f5", color="#999999"];' . "\n"; + } + + if ($firstBranchId !== null) { + $dot .= sprintf(" entry -> b%d;\n", $firstBranchId); + } + + $edgePathClasses = $this->buildEdgePathClasses($methodData, $paths); + + foreach ($methodData->branches as $branchId => $branch) { + foreach ($branch->out as $edgeIndex => $destBranchId) { + $destNode = $destBranchId === self::XDEBUG_EXIT_BRANCH + ? 'exit' + : sprintf('b%d', $destBranchId); + + $edgeHit = isset($branch->out_hit[$edgeIndex]) && $branch->out_hit[$edgeIndex] > 0; + $color = $edgeHit ? $coveredBorder : $uncoveredEdge; + + $edgeKey = $destBranchId === self::XDEBUG_EXIT_BRANCH + ? $branchId . '-exit' + : $branchId . '-' . $destBranchId; + + $attrs = sprintf('color="%s"', $color); + $attrs .= sprintf(', id="edge-%s"', $edgeKey); + + if (isset($edgePathClasses[$edgeKey])) { + $attrs .= sprintf(', class="%s"', implode(' ', $edgePathClasses[$edgeKey])); + } + + $dot .= sprintf( + " b%d -> %s [%s];\n", + $branchId, + $destNode, + $attrs, + ); + } + } + + $dot .= "}\n"; + + return $dot; + } + + /** + * @param null|array $paths + * + * @return array> + */ + private function buildEdgePathClasses(ProcessedFunctionCoverageData $methodData, ?array $paths): array + { + $edgePathClasses = []; + + if ($paths === null) { + return $edgePathClasses; + } + + $pathIndex = 0; + + foreach ($paths as $path) { + $branchIds = $path->path; + + for ($i = 0; $i < count($branchIds) - 1; $i++) { + $edgeKey = $branchIds[$i] . '-' . $branchIds[$i + 1]; + + if (!isset($edgePathClasses[$edgeKey])) { + $edgePathClasses[$edgeKey] = []; + } + + $edgePathClasses[$edgeKey][] = 'path-' . $pathIndex; + } + + $lastBranchId = $branchIds[count($branchIds) - 1]; + + if (isset($methodData->branches[$lastBranchId])) { + foreach ($methodData->branches[$lastBranchId]->out as $dest) { + if ($dest === self::XDEBUG_EXIT_BRANCH) { + $edgeKey = $lastBranchId . '-exit'; + + if (!isset($edgePathClasses[$edgeKey])) { + $edgePathClasses[$edgeKey] = []; + } + + $edgePathClasses[$edgeKey][] = 'path-' . $pathIndex; + } + } + } + + $pathIndex++; + } + + return $edgePathClasses; + } + + private function dotToSvg(string $dot): string + { + if ($this->dotAvailable === false) { + return ''; + } + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = @proc_open('dot -Tsvg', $descriptorSpec, $pipes); + + if ($process === false) { + $this->dotAvailable = false; + + return ''; + } + + // Use non-blocking I/O to avoid deadlock when dot's output + // buffer fills up before we finish writing the input + stream_set_blocking($pipes[1], false); + + $written = 0; + $length = strlen($dot); + $svg = ''; + + while ($written < $length) { + $chunk = @fwrite($pipes[0], substr($dot, $written)); + + if ($chunk === false) { + break; + } + + $written += $chunk; + + // Drain available output to prevent pipe buffer deadlock + $svg .= stream_get_contents($pipes[1]); + } + + fclose($pipes[0]); + + // Read remaining output + stream_set_blocking($pipes[1], true); + $svg .= stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($exitCode !== 0 || $svg === '') { + $this->dotAvailable = false; + + return ''; + } + + $this->dotAvailable = true; + + // Strip XML declaration and DOCTYPE, keep only the element + return preg_replace('/^.*?(thresholds, $hasBranchCoverage, + $this->colors, ); $directory->render($report, $target . 'index.html'); diff --git a/src/Report/Html/Renderer/File.php b/src/Report/Html/Renderer/File.php index 3f00568b5..92c029230 100644 --- a/src/Report/Html/Renderer/File.php +++ b/src/Report/Html/Renderer/File.php @@ -95,6 +95,7 @@ use function htmlspecialchars; use function implode; use function is_string; +use function json_encode; use function ksort; use function max; use function min; @@ -113,6 +114,7 @@ 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; @@ -203,7 +205,16 @@ final class File extends Renderer /** * @var array> */ - private static array $formattedSourceCache = []; + private static array $formattedSourceCache = []; + private ?ControlFlowGraph $controlFlowGraph = null; + private readonly Colors $colors; + + public function __construct(string $templatePath, string $generator, string $date, Thresholds $thresholds, bool $hasBranchCoverage, Colors $colors) + { + parent::__construct($templatePath, $generator, $date, $thresholds, $hasBranchCoverage); + + $this->colors = $colors; + } public function render(FileNode $node, string $file): void { @@ -1018,8 +1029,9 @@ private function renderPathStructure(FileNode $node): string } $paths .= sprintf( - '
%d%s%s%s
' . "\n", + '
%d%s%s%s
' . "\n", $statusClass, + $pathIndex - 1, $pathIndex, $branchesLabel, $statusLabel, @@ -1031,6 +1043,39 @@ private function renderPathStructure(FileNode $node): string $paths .= '
#BranchesStatusTests
' . "\n"; + $pathsJson = []; + $pathIdx = 0; + + foreach ($methodData->paths as $path) { + $edges = []; + $branchIds = $path->path; + + for ($i = 0; $i < count($branchIds) - 1; $i++) { + $edges[] = $branchIds[$i] . '-' . $branchIds[$i + 1]; + } + + $lastBranchId = $branchIds[count($branchIds) - 1]; + + if (isset($methodData->branches[$lastBranchId])) { + foreach ($methodData->branches[$lastBranchId]->out as $dest) { + if ($dest === ControlFlowGraph::XDEBUG_EXIT_BRANCH) { + $edges[] = $lastBranchId . '-exit'; + } + } + } + + $pathsJson[$pathIdx] = $edges; + $pathIdx++; + } + + $svg = $this->controlFlowGraph()->renderSvg($methodData, $methodData->paths); + + $paths .= sprintf( + '
%s
' . "\n", + htmlspecialchars(json_encode($pathsJson), self::HTML_SPECIAL_CHARS_FLAGS), + $svg, + ); + if ($pathCount > 100) { $paths .= '' . "\n"; } @@ -1041,6 +1086,15 @@ private function renderPathStructure(FileNode $node): string return $pathsTemplate->render(); } + private function controlFlowGraph(): ControlFlowGraph + { + if ($this->controlFlowGraph === null) { + $this->controlFlowGraph = new ControlFlowGraph($this->colors); + } + + return $this->controlFlowGraph; + } + private function renderLine(Template $template, int $lineNumber, string $lineContent, string $class, string $popover, string $coverageCount = '', string $coverageCountClass = 'col-0'): string { $template->setVar( diff --git a/src/Report/Html/Renderer/Template/css/style.css b/src/Report/Html/Renderer/Template/css/style.css index 60bb306ab..916612d37 100644 --- a/src/Report/Html/Renderer/Template/css/style.css +++ b/src/Report/Html/Renderer/Template/css/style.css @@ -309,3 +309,40 @@ table#code td:first-of-type a { .progress-bar.bg-danger { background-color: var(--phpunit-danger-bar) !important; } + +.cfg-graph { + margin: 1em 0; + padding: 0.5em; + border: 1px solid var(--bs-border-color); + border-radius: 4px; + overflow-x: auto; + background: var(--bs-body-bg); +} + +.cfg-graph svg { + max-width: 100%; + height: auto; + display: block; +} + +.cfg-graph .edge.highlighted path, +.cfg-graph .edge.highlighted polygon { + stroke: var(--bs-link-color); + stroke-width: 3px; + fill: var(--bs-link-color); +} + +.cfg-graph .node.highlighted polygon, +.cfg-graph .node.highlighted ellipse { + stroke: var(--bs-link-color); + stroke-width: 3px; +} + +.path-row { + cursor: pointer; +} + +.path-row.path-selected { + outline: 2px solid var(--bs-link-color); + outline-offset: -2px; +} diff --git a/src/Report/Html/Renderer/Template/js/file.js b/src/Report/Html/Renderer/Template/js/file.js index 8eb90e06d..c4582c8be 100644 --- a/src/Report/Html/Renderer/Template/js/file.js +++ b/src/Report/Html/Renderer/Template/js/file.js @@ -65,4 +65,36 @@ $(function () { hideAllExcept(null); } }); + + // Path highlighting in control flow graphs + $(document).on('click', '.path-row', function () { + var $row = $(this); + var pathIndex = $row.data('path-index'); + var $table = $row.closest('table'); + var $graph = $table.nextAll('.cfg-graph').first(); + + if (!$graph.length) return; + + var pathsData = $graph.data('paths'); + + // Reset all highlights + $graph.find('.edge, .node').removeClass('highlighted'); + + // Toggle: if already selected, deselect + if ($row.hasClass('path-selected')) { + $('.path-row').removeClass('path-selected'); + return; + } + + $('.path-row').removeClass('path-selected'); + $row.addClass('path-selected'); + + if (!pathsData || !pathsData[pathIndex]) return; + + // Highlight edges for this path + var edges = pathsData[pathIndex]; + for (var i = 0; i < edges.length; i++) { + $graph.find('#edge-' + edges[i]).addClass('highlighted'); + } + }); }); 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 4ea5b7e92..34d9e5248 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 @@ -186,21 +186,24 @@
foo 0/2 #BranchesStatusTests -1L12L15L15Not covered— -2L12L15L15Not covered— +1L12L15L15Not covered— +2L12L15L15Not covered— +
%A
{closure:%se.php:14-14} 0/1
- +
#BranchesStatusTests
1L14Not covered
1L14Not covered
+
%A
{main} 1/1
- +
#BranchesStatusTests
1L19Covered1 test
1L19Covered1 test
+
%A