Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
190 / 190
100.00% covered (success)
100.00%
16 / 16
CRAP
100.00% covered (success)
100.00%
1 / 1
XDataTestMaster
100.00% covered (success)
100.00%
190 / 190
100.00% covered (success)
100.00%
16 / 16
69
100.00% covered (success)
100.00%
1 / 1
 verifyLibrary
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
 verifyGlobalPrefixCodeIsConfiguredForLibrary
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 verifyXDataKeysMatchClassStringsFromDir
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
9
 getThrowableClassStrings
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 verifyGetLocalCodesArrayHasUniqueIntegerValues
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 verifyGetLocalMessagesArrayHasStringsForValues
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 verifyExceptionConstructorIsCorrect
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
6
 verifyExceptionAndMessageParametersMatch
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 exceptionHasExplicitConstructor
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 verifyExceptionCanBeInstantiated
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 verifyExceptionExtendsPvcStockException
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseVariableNamesFromMessage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 parameterIsThrowable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parameterHasDefaultValueOfNull
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 createDummyParamValueBasedOnType
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 getReflectionTypeName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * @package pvcErr
5 * @author: Doug Wilbourne (dougwilbourne@gmail.com)
6 */
7
8declare(strict_types=1);
9
10namespace pvc\err;
11
12use PHPUnit\Framework\TestCase;
13use pvc\err\stock\Exception;
14use pvc\interfaces\err\XDataInterface;
15use ReflectionClass;
16use ReflectionException;
17use ReflectionNamedType;
18use ReflectionParameter;
19use ReflectionType;
20use Throwable;
21
22/**
23 * Class XDataTestMaster
24 */
25class XDataTestMaster extends TestCase
26{
27    /**
28     * verifyLibrary
29     * @param XDataInterface $xData
30     * @throws ReflectionException
31     * @throws ReflectionException
32     */
33    public function verifyLibrary(XDataInterface $xData): bool
34    {
35        /** @var array<class-string<Throwable>> $throwableClassStrings */
36        $throwableClassStrings = $this->getThrowableClassStrings($xData);
37
38        $result = $this->verifyXDataKeysMatchClassStringsFromDir($xData, $throwableClassStrings);
39        $result = $result && $this->verifyGetLocalCodesArrayHasUniqueIntegerValues($xData);
40        $result = $result && $this->verifyGetLocalMessagesArrayHasStringsForValues($xData);
41
42        $result = $result && $this->verifyGlobalPrefixCodeIsConfiguredForLibrary($xData);
43
44        foreach ($throwableClassStrings as $classString) {
45            $message = $xData->getXMessageTemplate($classString);
46            $messageVariables = $this->parseVariableNamesFromMessage($message);
47
48            $result = $result && $this->verifyExceptionConstructorIsCorrect($classString);
49            $result = $result && $this->verifyExceptionAndMessageParametersMatch($classString, $messageVariables);
50            $result = $result && $this->verifyExceptionCanBeInstantiated($classString);
51            $result = $result && $this->verifyExceptionExtendsPvcStockException($classString);
52        }
53        return $result;
54    }
55
56    /**
57     * verifyGlobalPrefixCodeIsConfiguredForLibrary
58     * @param XDataInterface $xData
59     * @return bool
60     */
61    public function verifyGlobalPrefixCodeIsConfiguredForLibrary(XDataInterface $xData): bool
62    {
63        $xDataReflection = new ReflectionClass($xData::class);
64        $nsName = $xDataReflection->getNamespaceName();
65        if (0 == XCodePrefixes::getXCodePrefix($nsName)) {
66            echo sprintf(
67                '%s is not a registered namespace in the XCodePrefixes registry.'
68                .PHP_EOL,
69                $nsName
70            );
71            return false;
72        }
73        return true;
74    }
75
76    /**
77     * verifyXDataKeysMatchClassStringsFromDir
78     * @param XDataInterface $xData
79     * @param array<string> $throwableClassStrings
80     * @return bool
81     */
82    public function verifyXDataKeysMatchClassStringsFromDir(XDataInterface $xData, array $throwableClassStrings): bool
83    {
84        $codesArray = $xData->getLocalXCodes();
85        $messagesArray = $xData->getXMessageTemplates();
86        $keysForCodes = array_keys($codesArray);
87        $keysForMessages = array_keys($messagesArray);
88
89        $result = true;
90
91        $keysForCodesThatHaveNoExceptionDefined = array_diff($keysForCodes, $throwableClassStrings);
92        if ($keysForCodesThatHaveNoExceptionDefined !== []) {
93            foreach ($keysForCodesThatHaveNoExceptionDefined as $key) {
94                echo sprintf(
95                    "codes key %s has no corresponding exception defined."
96                    .PHP_EOL,
97                    $key
98                );
99            }
100            $result = false;
101        }
102
103
104        $keysForMessagesThatHaveNoExceptionDefined = array_diff($keysForMessages, $throwableClassStrings);
105        if ($keysForMessagesThatHaveNoExceptionDefined !== []) {
106            foreach ($keysForMessagesThatHaveNoExceptionDefined as $key) {
107                echo sprintf(
108                    "messages key %s has no corresponding exception defined."
109                    .PHP_EOL,
110                    $key
111                );
112            }
113            $result = false;
114        }
115
116        $exceptionsWithNoCodeDefined = array_diff($throwableClassStrings, $keysForCodes);
117        if ($exceptionsWithNoCodeDefined !== []) {
118            foreach ($exceptionsWithNoCodeDefined as $key) {
119                echo sprintf(
120                    "exception %s has no corresponding code defined.".PHP_EOL,
121                    $key
122                );
123            }
124            $result = false;
125        }
126
127        $exceptionsWithNoMessageDefined = array_diff($throwableClassStrings, $keysForMessages);
128        if ($exceptionsWithNoMessageDefined !== []) {
129            foreach ($exceptionsWithNoMessageDefined as $key) {
130                echo sprintf(
131                    "exception %s has no corresponding message defined."
132                    .PHP_EOL,
133                    $key
134                );
135            }
136            $result = false;
137        }
138
139        return $result;
140    }
141
142    /**
143     * getExceptionClassStringsFromDir
144     * @return array<int, class-string>
145     */
146    public function getThrowableClassStrings(XDataInterface $xData): array
147    {
148        /**
149         * reflect XData and get the directory portion of the file name
150         */
151        $reflectedXData = new ReflectionClass($xData);
152        $filePath = $reflectedXData->getFileName() ?: '';
153        $dir = pathinfo($filePath, PATHINFO_DIRNAME);
154
155        /**
156         * put all the files from the directory into an array, removing any directory entries.  Typehint files so
157         * phpstan does not complain. scandir returns false if its argument is not a directory, but in this case that
158         * cannot be true because the directory is pulled via pathinfo.
159         */
160        /** @var array<int, string> $files */
161        $files = scandir($dir);
162        foreach ($files as $index => $file) {
163            if (is_dir($file)) {
164                unset($files[$index]);
165            }
166        }
167
168        /** @var array<int, class-string> $classStrings */
169        $classStrings = [];
170
171        foreach ($files as $file) {
172            /**
173             * get the class string by parsing the file
174             */
175            $fileContents = file_get_contents($dir . DIRECTORY_SEPARATOR . $file) ?: '';
176            $classString = Exception::getClassStringFromFileContents($fileContents);
177
178            /**
179             * validate the class string:  must be reflectable (i.e. an object) and Throwable.  In order to provide
180             * better diagnostic information, we are not checking to see if the exception extends
181             * \pvc\err\stock\Exception at this point.
182             */
183            if ($classString) {
184                try {
185                    $reflected = new ReflectionClass($classString);
186                    if ($reflected->implementsInterface(Throwable::class)) {
187                        $classStrings[] = $classString;
188                    }
189                } catch (ReflectionException) {
190                    /** either reflection failed or it was not Throwable */
191                }
192            }
193        }
194
195        return $classStrings;
196    }
197
198    /**
199     * verifyGetLocalCodesArrayHasUniqueIntegerValues
200     * @param XDataInterface $xData
201     * @return bool
202     */
203    public function verifyGetLocalCodesArrayHasUniqueIntegerValues(XDataInterface $xData): bool
204    {
205        $codesArray = $xData->getLocalXCodes();
206        $result = true;
207        /**
208         * verify that the count of unique codes equals the total count of codes
209         */
210        if (count(array_unique($codesArray)) !== count($codesArray)) {
211            echo "not all exception codes are unique.".PHP_EOL;
212            $result = false;
213        }
214
215        /**
216         * verify $codes is all integers.
217         * if $codes is empty, $initialValue will be false and then array_reduce returns false.
218         */
219        $initialValue = $codesArray !== [];
220        $callback = (fn($carry, $x): bool => $carry && is_int($x));
221        /** @noinspection PhpPointlessBooleanExpressionInConditionInspection */
222        if (false == (array_reduce($codesArray, $callback, $initialValue))) {
223            echo "not all exception codes are integers.".PHP_EOL;
224            $result = false;
225        }
226        return $result;
227    }
228
229    /**
230     * this method is borrowed from the pvc\err\pvc\Exception class.  Originally, I even
231     * had a separate utilities class which provided the code that could be shared.  But in the interests of
232     * keeping the publicly available methods as few as possible, I chose to duplicate the code here....
233     */
234
235    public function verifyGetLocalMessagesArrayHasStringsForValues(XDataInterface $xData): bool
236    {
237        $messagesArray = $xData->getXMessageTemplates();
238        $result = true;
239        /**
240         * verify $messages is all strings
241         * if messages array is empty, $initialValue will be false and then array_reduce returns false.
242         */
243        $initialValue = $messagesArray !== [];
244        $callback = (fn($carry, $x): bool => $carry && is_string($x));
245        /** @noinspection PhpPointlessBooleanExpressionInConditionInspection */
246        if (false == (array_reduce($messagesArray, $callback, $initialValue))) {
247            echo "not all exception messages are strings.".PHP_EOL;
248            $result = false;
249        }
250        return $result;
251    }
252
253    /**
254     * verifyExceptionConstructorIsCorrect
255     * @param class-string<Throwable> $classString
256     * @return bool
257     * @throws ReflectionException
258     */
259    public function verifyExceptionConstructorIsCorrect(string $classString): bool
260    {
261        $reflected = new ReflectionClass($classString);
262        if (!$this->exceptionHasExplicitConstructor($reflected)) {
263            /**
264             * It is OK if there is no constructor for the exception as long as the message has no variables in it.
265             * The rest of the tests depend on there being a constructor defined, so return true now.
266             */
267            return true;
268        }
269
270        $result = true;
271        $constructor = $reflected->getConstructor();
272        $reflectionParams = $constructor ? $constructor->getParameters() : [];
273        $countOfParams = count($reflectionParams);
274
275        /**
276         * all exceptions with a constructor must have at least one parameter.  Return false because subsequent tests
277         * depend on there being at least one parameter
278         */
279        if ($countOfParams == 0) {
280            echo sprintf(
281                "%s has no parameters and must have at least a \$prev parameter."
282                .PHP_EOL,
283                $classString
284            );
285            return false;
286        }
287
288        /**
289         * ensure that last param is Throwable
290         */
291        $lastParam = $reflectionParams[$countOfParams - 1];
292        if (!$this->parameterIsThrowable($lastParam)) {
293            echo sprintf(
294                "The last parameter (e.g. \$prev) of %s is not Throwable."
295                .PHP_EOL,
296                $classString
297            );
298            $result = false;
299        }
300
301        /**
302         * ensure that the last parameter has a default of null
303         */
304        if (!$this->parameterHasDefaultValueOfNull($lastParam)) {
305            $format
306                = "The last parameter (e.g. \$prev) of %s does not have a default value of null."
307                .PHP_EOL;
308            echo sprintf($format, $classString);
309            $result = false;
310        }
311        return $result;
312    }
313
314    /**
315     * verifyExceptionAndMessageParametersMatch
316     * @param class-string<Throwable> $classString
317     * @param array<string> $messageParameters
318     * @return bool
319     * @throws ReflectionException
320     */
321    public function verifyExceptionAndMessageParametersMatch(
322        string $classString,
323        array $messageParameters
324    ): bool {
325        $reflected = new ReflectionClass($classString);
326        /**
327         * if the exception has no constructor but there are variables in the message then return false. If the
328         * exception has no constructor and there are no variables in the message, then return true.
329         */
330        if (!$this->exceptionHasExplicitConstructor($reflected)) {
331            if ($messageParameters !== []) {
332                echo sprintf(
333                    "%s has no constructor but has message variables in its exception data file."
334                    .PHP_EOL,
335                    $classString
336                );
337                return false;
338            }
339            return true;
340        }
341
342        /**
343         * We know the exception has a constructor, so now we can get the parameters into an array, remove the $prev
344         * parameter because it does not appear in the message and then compare parameter names and message variable
345         * names.
346         */
347        $constructor = $reflected->getConstructor();
348        $reflectionParams = $constructor ? $constructor->getParameters() : [];
349
350        /**
351         * bump the last parameter ($prev) off the array.
352         */
353        array_pop($reflectionParams);
354
355        /**
356         * verify that the parameter names all match the variable names in the messages.  Variable names in the
357         * messages do NOT have to be in the same order as they appear in the constructor declaration for the
358         * exception.
359         */
360        $paramNames = [];
361        foreach ($reflectionParams as $param) {
362            $paramNames[] = $param->getName();
363        }
364
365        sort($paramNames);
366        sort($messageParameters);
367
368        if ($paramNames !== $messageParameters) {
369            $format
370                = "The parameters of %s do not match the variable names in the corresponding message."
371                .PHP_EOL;
372            echo sprintf($format, $classString);
373            return false;
374        }
375
376        return true;
377    }
378
379    /**
380     * exceptionHasExplicitConstructor
381     * @param ReflectionClass<Throwable> $reflected
382     * @return bool
383     * returns true if there is a __construct method explicit defined in the class
384     * returns false if this is a child class and the constructor is inherited
385     * returns false if this is a child class and there is no constructor anywhere up the inheritance chain
386     */
387    public function exceptionHasExplicitConstructor(ReflectionClass $reflected): bool
388    {
389        /**
390         * The getConstructor method returns null if there is no constructor for the class at all.  This can occur in
391         * either of two ways: 1) this class has no parent and no __construct method.  2) This class has a parent
392         * AND there is no constructor anywhere up the inheritance chain.
393         *
394         * Because all exceptions in the exception library SHOULD extend \pvc\err\stock\Exception, the getConstructor
395         * method should never return null in this context.  But, it is a public method so to be safe.....
396         */
397        $isChildClass = (bool)$reflected->getParentClass();
398
399        $parent = $reflected->getParentClass();
400
401        $parentConstructor = ($parent ? $parent->getConstructor() : false);
402
403        $myConstructor = $reflected->getConstructor();
404
405        /**
406         * if $myConstructor is not null and the constructor of the parent class is the same as the constructor of this
407         * class, then this class inherited the constructor, e.g. we know that there is no explicit constructor
408         * in $myException
409         */
410        if ($isChildClass) {
411            return !($myConstructor && ($myConstructor == $parentConstructor));
412        }
413        /**
414         * otherwise, this is a standalone class
415         */
416        return (bool)$myConstructor;
417    }
418
419    /**
420     * verifyExceptionCanBeInstantiated
421     * @param class-string $classString
422     * @return bool
423     * @throws ReflectionException
424     */
425    public function verifyExceptionCanBeInstantiated(string $classString): bool
426    {
427        $reflected = new ReflectionClass($classString);
428        $constructor = $reflected->getConstructor();
429        $reflectionParams = $constructor ? $constructor->getParameters() : [];
430
431        /**
432         * create array of dummy parameters, so we can instantiate the class.  Do not create a dummy parameter
433         * for the $prev parameter, which has been tested separately, and we know is both Throwable and has a
434         * default of null
435         */
436        array_pop($reflectionParams);
437        $paramValues = [];
438        foreach ($reflectionParams as $param) {
439            $typeName = $this->getReflectionTypeName($param->getType());
440            $paramValues[] = $this->createDummyParamValueBasedOnType($typeName);
441        }
442
443        /**
444         * verify that we can instantiate the exception class so the constructor is exercised.  Then we can
445         * claim 100% line coverage in the testing
446         */
447        $instance = $reflected->newInstanceArgs($paramValues);
448        return ($instance instanceof $classString);
449    }
450
451    /**
452     * verifyExceptionExtendsPvcStockException
453     * @param class-string $classString
454     * @return bool
455     * @throws ReflectionException
456     */
457    public function verifyExceptionExtendsPvcStockException(string $classString): bool
458    {
459        $reflected = new ReflectionClass($classString);
460        return $reflected->isSubclassOf(Exception::class);
461    }
462
463    /**
464     * parseVariableNamesFromMessage
465     * @param string $message
466     * @return array<string>
467     */
468    public function parseVariableNamesFromMessage(string $message): array
469    {
470        /**
471         * looking for any group of non-whitespace characters starting with '${' and ending with '}'.  The capturing
472         * subpattern puts the subpattern matches into the $matches[1]
473         */
474        $pattern = '/\$\{(\S*)}/';
475        $result = preg_match_all($pattern, $message, $matches);
476        /**
477         * preg_match_all returns false on failure, or the number of matches it found (could be zero).  So, if it
478         * found a non-zero number of matches, return the captured subpatterns (everything in the matches array
479         * except the first element) or else return an empty array.
480         */
481        return $result ? $matches[1] : [];
482    }
483
484    /**
485     * parameterIsThrowable
486     * @param ReflectionParameter $param
487     * @return bool
488     */
489    public function parameterIsThrowable(ReflectionParameter $param): bool
490    {
491        $reflectionType = $param->getType();
492
493        /**
494         * with PHP 8+ ReflectionType has 3 subtypes in order to accommodate intersection and union types.  Because
495         * we are looking for Throwable only (e.g. a named type, not an intersection or a union), we can test for
496         * ReflectionNamedType.
497         */
498        /** @noinspection PhpPointlessBooleanExpressionInConditionInspection */
499        if ((false == ($reflectionType instanceof ReflectionNamedType))) {
500            return false;
501        }
502
503        return ($reflectionType->getName() === 'Throwable');
504    }
505
506    /**
507     * parameterHasDefaultValueOfNull
508     * @param ReflectionParameter $param
509     * @return bool
510     * @throws ReflectionException
511     */
512    public function parameterHasDefaultValueOfNull(ReflectionParameter $param): bool
513    {
514        return ($param->isOptional() && (null == $param->getDefaultValue()));
515    }
516
517    /**
518     * createDummyParamValueBasedOnType
519     * @param string $paramType
520     * @return int|string|true
521     *
522     * In the event that the method parameter is untyped, $paramType will be null.
523     *
524     * Of not null, ReflectionType is actually an instance of one of three kinds of subtypes: ReflectionNamedType,
525     * ReflectionUnionType, and ReflectionIntersectionType, which is in keeping with the addition of union and
526     * intersection data types in parameter declarations.  For union and intersection, we get an array of
527     * reflection types via the getTypes method.  In both cases we can simply use the first member of the
528     * array as a suitable candidate.  Because PHP does not support abstract data types per se, we do not need to
529     * recurse.  We know that the types inside the array must be bool|int|float|string|array|resource|object.
530     */
531    public function createDummyParamValueBasedOnType(string $paramType): bool|int|string
532    {
533        return match ($paramType) {
534            'string' => 'foo',
535            'integer', 'int' => 5,
536            'bool' => true,
537            default => '{' . $paramType . '}',
538        };
539    }
540
541    /**
542     * getReflectionNamedType
543     * @param ReflectionType|null $paramType
544     * @return string
545     * returns a base datatype suitable for creating a dummy parameter value
546     */
547    public function getReflectionTypeName(?ReflectionType $paramType): string
548    {
549        /**
550         * if it's a named type, return the name
551         */
552        if ($paramType instanceof \ReflectionNamedType) {
553            return $paramType->getName();
554        }
555
556        /**
557         * If the parameter type is not a 'named type' (one of the basic php data types), it must be a compound type
558         * (intersection or union).  While it is not too hard to convert basic types to a sensible string (casting
559         * integers to strings, boolean to true / false, etc.), it is hard to see a sensible strategy for automagically
560         * converting a compound type to a string.  By returning 'string', we are really just saying 'don't do
561         * anything to the parameter, let php do whatever it is going to do to cast it to a string on the fly.'
562         */
563        return 'string';
564    }
565}