Passed
Push — master ( b8ac87...e44da5 )
by Eric
03:40 queued 01:13
created

CoverageCheck::process()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 31
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 3
eloc 12
c 3
b 0
f 0
nc 3
nop 0
dl 0
loc 31
rs 9.8666
ccs 11
cts 11
cp 1
crap 3
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * This file is part of PHPUnit Coverage Check.
7
 *
8
 * (c) Eric Sizemore <[email protected]>
9
 * (c) Richard Regeer <[email protected]>
10
 *
11
 * This source file is subject to the MIT license. For the full copyright,
12
 * license information, and credits/acknowledgements, please view the LICENSE
13
 * and README files that were distributed with this source code.
14
 */
15
16
namespace Esi\CoverageCheck;
17
18
use Esi\CoverageCheck\Exceptions\FailedToGetFileContentsException;
19
use Esi\CoverageCheck\Exceptions\InvalidInputFileException;
20
use Esi\CoverageCheck\Exceptions\NotAValidCloverFileException;
21
use Esi\CoverageCheck\Exceptions\ThresholdOutOfBoundsException;
22
use InvalidArgumentException;
23
use RuntimeException;
24
use SimpleXMLElement;
25
26
use function array_map;
27
use function file_get_contents;
28
29
/**
30
 * @see Command\CoverageCheckCommand
31
 * @see Tests\CoverageCheckTest
32
 */
33
final class CoverageCheck
34
{
35
    /**
36
     * Message returned if there is not enough data to calculate coverage.
37
     *
38
     * Mirrored in \Esi\CoverageCheck\Command\CoverageCheckCommand::ERROR_INSUFFICIENT_DATA without prefix.
39
     *
40
     * @see Command\CoverageCheckCommand::ERROR_INSUFFICIENT_DATA
41
     * @since 3.0.0
42
     */
43
    public const string ERROR_INSUFFICIENT_DATA = '[ERROR] Insufficient data for calculation. Please add more code.';
44
45
    /**
46
     * Message returned if coverage falls below a given threshold.
47
     *
48
     * Mirrored in \Esi\CoverageCheck\Command\CoverageCheckCommand::ERROR_COVERAGE_BELOW_THRESHOLD without prefix.
49
     *
50
     * @see Command\CoverageCheckCommand::ERROR_COVERAGE_BELOW_THRESHOLD
51
     * @since 3.0.0
52
     */
53
    public const string ERROR_COVERAGE_BELOW_THRESHOLD = '[ERROR] Total code coverage is %s%% which is below the accepted %d%%';
54
55
    /**
56
     * Message returned if coverage meets or exceeds a given threshold.
57
     *
58
     * Mirrored in \Esi\CoverageCheck\Command\CoverageCheckCommand::OK_TOTAL_CODE_COVERAGE without prefix.
59
     *
60
     * @see Command\CoverageCheckCommand::OK_TOTAL_CODE_COVERAGE
61
     * @since 3.0.0
62
     */
63
    public const string OK_TOTAL_CODE_COVERAGE = '[OK] Total code coverage is %s%%';
64
65
    /**
66
     * Xpath expression for getting each files' data in a clover report.
67
     *
68
     * @since 2.0.0
69
     */
70
    protected const string XPATH_FILES = '//file';
71
72
    /**
73
     * Xpath expression for getting the project total metrics in a clover report.
74
     */
75
    protected const string XPATH_METRICS = '//project/metrics';
76
77
    /**
78
     * Configurable options.
79
     *
80
     * @see self::setCloverFile()
81
     * @see self::setThreshold()
82
     * @see self::setOnlyPercentage()
83
     */
84
    private string $cloverFile = 'clover.xml';
85
86
    private bool $onlyPercentage = false;
87
88
    private int $threshold = 100;
89
90
    /**
91
     * Simple getters.
92
     */
93
94 1
    public function getCloverFile(): string
95
    {
96 1
        return $this->cloverFile;
97
    }
98
99 5
    public function getOnlyPercentage(): bool
100
    {
101 5
        return $this->onlyPercentage;
102
    }
103
104 7
    public function getThreshold(): int
105
    {
106 7
        return $this->threshold;
107
    }
108
109
    /**
110
     * Processes the coverage data with the given clover file and threshold, and returns the information
111
     * similar to how the Console application will.
112
     *
113
     * This is mainly useful for those that may wish to use this library outside the CLI/Console or PHAR.
114
     *
115
     * @throws InvalidArgumentException If the clover file does not exist, or the threshold is not within
116
     *                                  defined range (>= 1 <= 100).
117
     */
118 8
    public function nonConsoleCall(string $cloverFile, int $threshold = 100, bool $onlyPercentage = false): string
119
    {
120 8
        $this->setCloverFile($cloverFile)
121 8
            ->setThreshold($threshold)
122 8
            ->setOnlyPercentage($onlyPercentage);
123
124 8
        $results = $this->process();
125
126 5
        if ($results === false) {
127 1
            return self::ERROR_INSUFFICIENT_DATA;
128
        }
129
130 4
        if ($results < $threshold && !$onlyPercentage) {
131 1
            return \sprintf(
132 1
                self::ERROR_COVERAGE_BELOW_THRESHOLD,
133 1
                Utils::formatCoverage($results),
134 1
                $threshold
135 1
            );
136
        }
137
138 3
        if ($onlyPercentage) {
139 2
            return Utils::formatCoverage($results);
140
        }
141
142 1
        return \sprintf(self::OK_TOTAL_CODE_COVERAGE, Utils::formatCoverage($results));
143
    }
144
145
    /**
146
     * Parses the clover xml file for coverage metrics.
147
     *
148
     * According to Atlassian:
149
     *     TPC = (coveredconditionals + coveredstatements + coveredmethods) / (conditionals + statements + methods)
150
     *
151
     * Though it appears elements + coveredelements should work the same, I am sticking with Atlassian's
152
     * calculation.
153
     *
154
     * @see self::loadMetrics()
155
     * @see https://confluence.atlassian.com/pages/viewpage.action?pageId=79986990
156
     * @see https://ocramius.github.io/blog/automated-code-coverage-check-for-github-pull-requests-with-travis/
157
     */
158 17
    public function process(): false|float
159
    {
160 17
        $rawMetrics = $this->loadMetrics() ?? false;
161
162
        // Ignoring coverage here as theoretically this should not happen
163
        //@codeCoverageIgnoreStart
164
        if ($rawMetrics === false) {
165
            return false;
166
        }
167
        //@codeCoverageIgnoreEnd
168
169
        /**
170
         * @var array<string> $metrics
171
         */
172 11
        $metrics = ((array) $rawMetrics[0])['@attributes'];
173
174 11
        $metrics = array_map(static fn (string $value): int => \intval($value), $metrics);
175
176 11
        unset($rawMetrics);
177
178 11
        $coveredMetrics = $metrics['coveredconditionals'] + $metrics['coveredstatements'] + $metrics['coveredmethods'];
179 11
        $totalMetrics   = $metrics['conditionals'] + $metrics['statements'] + $metrics['methods'];
180
181 11
        unset($metrics);
182
183 11
        if ($totalMetrics === 0) {
184 3
            return false;
185
        }
186
187 8
        return $coveredMetrics / $totalMetrics * 100;
188
    }
189
190
    /**
191
     * Parses the clover xml file for coverage metrics by file.
192
     *
193
     * @see self::process()
194
     * @see self::loadMetrics()
195
     * @see https://confluence.atlassian.com/pages/viewpage.action?pageId=79986990
196
     * @see https://ocramius.github.io/blog/automated-code-coverage-check-for-github-pull-requests-with-travis/
197
     * @since 2.0.0
198
     *
199
     * @return false|array{
200
     *     fileMetrics: array<string, array{coveredMetrics: int, totalMetrics: int, percentage: float|int}>,
201
     *     totalCoverage: float|int
202
     * }
203
     */
204 3
    public function processByFile(): array|false
205
    {
206 3
        $fileMetrics          = [];
207 3
        $totalElementsCovered = 0;
208 3
        $totalElements        = 0;
209
210 3
        $rawMetrics = $this->loadMetrics(self::XPATH_FILES) ?? false;
211
212
        // Ignoring coverage here as theoretically this should not happen
213
        //@codeCoverageIgnoreStart
214
        if ($rawMetrics === false) {
215
            return false;
216
        }
217
        //@codeCoverageIgnoreEnd
218
219 3
        foreach ($rawMetrics as $rawMetric) {
220
            /**
221
             * @var array<string> $metrics
222
             */
223 3
            $metrics = ((array) $rawMetric->metrics)['@attributes'];
224
225 3
            $metrics = array_map(static fn (string $value): int => \intval($value), $metrics);
226
227 3
            $coveredMetrics = ($metrics['coveredconditionals'] + $metrics['coveredstatements'] + $metrics['coveredmethods']);
228 3
            $totalMetrics   = ($metrics['conditionals'] + $metrics['statements'] + $metrics['methods']);
229
230 3
            if ($totalMetrics === 0) {
231 1
                continue;
232
            }
233
234 2
            $coveragePercentage = $coveredMetrics / $totalMetrics * 100;
235 2
            $totalElementsCovered += $coveredMetrics;
236 2
            $totalElements        += $totalMetrics;
237
238 2
            $fileMetrics[(string) $rawMetric['name']] = [
239 2
                'coveredMetrics' => $coveredMetrics,
240 2
                'totalMetrics'   => $totalMetrics,
241 2
                'percentage'     => $coveragePercentage,
242 2
            ];
243
        }
244
245 3
        unset($rawMetrics);
246
247 3
        if ($totalElements === 0) {
248 1
            return false;
249
        }
250
251 2
        $totalCoverage = $totalElementsCovered / $totalElements * 100;
252
253 2
        return [
254 2
            'fileMetrics'   => $fileMetrics,
255 2
            'totalCoverage' => $totalCoverage,
256 2
        ];
257
    }
258
259
    /**
260
     * Simple setters.
261
     */
262
263
    /**
264
     * @throws InvalidInputFileException If the given file is empty or does not exist.
265
     */
266 25
    public function setCloverFile(string $cloverFile): CoverageCheck
267
    {
268 25
        if (!Utils::validateCloverFile($cloverFile)) {
269 2
            throw InvalidInputFileException::create($cloverFile);
270
        }
271
272 23
        $this->cloverFile = $cloverFile;
273
274 23
        return $this;
275
    }
276
277 21
    public function setOnlyPercentage(bool $enable = false): CoverageCheck
278
    {
279 21
        $this->onlyPercentage = $enable;
280
281 21
        return $this;
282
    }
283
284
    /**
285
     * @throws ThresholdOutOfBoundsException If the threshold is less than 1 or greater than 100.
286
     */
287 24
    public function setThreshold(int $threshold): CoverageCheck
288
    {
289 24
        if (!Utils::validateThreshold($threshold)) {
290 3
            throw ThresholdOutOfBoundsException::create($threshold);
291
        }
292
293 21
        $this->threshold = $threshold;
294
295 21
        return $this;
296
    }
297
298
    /**
299
     * Loads the clover xml data and runs an XML Xpath query.
300
     *
301
     * @internal
302
     *
303
     * @param self::XPATH_* $xpath
304
     *
305
     * @throws RuntimeException If file_get_contents fails or if XML data cannot be parsed, or
306
     *                          if the given file does not appear to be a valid clover file.
307
     *
308
     * @return null|array<SimpleXMLElement>|false
309
     */
310 20
    private function loadMetrics(string $xpath = self::XPATH_METRICS): null|array|false
311
    {
312 20
        $cloverData = file_get_contents($this->cloverFile);
313
314
        //@codeCoverageIgnoreStart
315
        if ($cloverData === false || $cloverData === '') {
316
            throw FailedToGetFileContentsException::create($this->cloverFile);
317
        }
318
        //@codeCoverageIgnoreEnd
319
320 20
        $xml = Utils::parseXml($cloverData);
321
322 20
        if (!Utils::isPossiblyClover($xml)) {
323 6
            throw NotAValidCloverFileException::create();
324
        }
325
326 14
        return $xml->xpath($xpath);
327
    }
328
}
329