Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
/**
18
 * Base test case class.
19
 *
20
 * @package    core
21
 * @category   test
22
 * @author     Tony Levi <tony.levi@blackboard.com>
23
 * @copyright  2015 Blackboard (http://www.blackboard.com)
24
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25
 */
26
 
1441 ariadna 27
use PHPUnit\Framework\MockObject\Rule\InvocationOrder;
28
use PHPUnit\Framework\TestCase;
1 efrain 29
 
30
/**
31
 * Base class for PHPUnit test cases customised for Moodle
32
 *
33
 * It is intended for functionality common to both basic and advanced_testcase.
34
 *
35
 * @package    core
36
 * @category   test
37
 * @author     Tony Levi <tony.levi@blackboard.com>
38
 * @copyright  2015 Blackboard (http://www.blackboard.com)
39
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40
 */
41
abstract class base_testcase extends PHPUnit\Framework\TestCase {
42
    // phpcs:disable
43
    // Following code is legacy code from phpunit to support assertTag
44
    // and assertNotTag.
45
 
46
    /**
47
     * Note: we are overriding this method to remove the deprecated error
48
     * @see https://tracker.moodle.org/browse/MDL-47129
49
     *
50
     * @param  array   $matcher
51
     * @param  string  $actual
52
     * @param  string  $message
53
     * @param  boolean $ishtml
54
     *
55
     * @deprecated 3.0
56
     */
57
    public static function assertTag($matcher, $actual, $message = '', $ishtml = true) {
1441 ariadna 58
        if ($ishtml) {
59
            $dom = self::loadHTML($actual);
60
        } else {
61
            $dom = (new PHPUnit\Util\Xml\Loader)->load($actual);
62
        }
1 efrain 63
        $tags = self::findNodes($dom, $matcher, $ishtml);
64
        $matched = (is_array($tags) && count($tags) > 0) && $tags[0] instanceof DOMNode;
65
        self::assertTrue($matched, $message);
66
    }
67
 
68
    /**
1441 ariadna 69
     * Load HTML into a DomDocument.
70
     *
71
     * Note: THis is a replacement for functionality removed from PHPUnit 10.
72
     *
73
     * @param string $actual
74
     * @throws \PHPUnit\Util\Xml\XmlException
75
     * @return DOMDocument
76
     */
77
    public static function loadHTML(string $actual): DOMDocument {
78
        if ($actual === '') {
79
            throw new \PHPUnit\Util\Xml\XmlException('Could not load XML from empty string');
80
        }
81
 
82
        $document = new DOMDocument;
83
        $document->preserveWhiteSpace = false;
84
 
85
        $internal  = libxml_use_internal_errors(true);
86
        $message   = '';
87
        $reporting = error_reporting(0);
88
 
89
        $loaded = $document->loadHTML($actual);
90
 
91
        foreach (libxml_get_errors() as $error) {
92
            $message .= "\n" . $error->message;
93
        }
94
 
95
        libxml_use_internal_errors($internal);
96
        error_reporting($reporting);
97
 
98
        if ($loaded === false) {
99
            if ($message === '') {
100
                $message = 'Could not load XML for unknown reason';
101
            }
102
 
103
            throw new \PHPUnit\Util\Xml\XmlException($message);
104
        }
105
 
106
        return $document;
107
    }
108
 
109
    /**
1 efrain 110
     * Note: we are overriding this method to remove the deprecated error
111
     * @see https://tracker.moodle.org/browse/MDL-47129
112
     *
113
     * @param  array   $matcher
114
     * @param  string  $actual
115
     * @param  string  $message
116
     * @param  boolean $ishtml
117
     *
118
     * @deprecated 3.0
119
     */
120
    public static function assertNotTag($matcher, $actual, $message = '', $ishtml = true) {
1441 ariadna 121
        if ($ishtml) {
122
            $dom = self::loadHTML($actual);
123
        } else {
124
            $dom = (new PHPUnit\Util\Xml\Loader)->load($actual);
125
        }
1 efrain 126
        $tags = self::findNodes($dom, $matcher, $ishtml);
127
        $matched = (is_array($tags) && count($tags) > 0) && $tags[0] instanceof DOMNode;
128
        self::assertFalse($matched, $message);
129
    }
130
 
131
    /**
132
     * Validate list of keys in the associative array.
133
     *
134
     * @param array $hash
135
     * @param array $validKeys
136
     *
137
     * @return array
138
     *
139
     * @throws PHPUnit\Framework\Exception
140
     */
141
    public static function assertValidKeys(array $hash, array $validKeys) {
142
        $valids = array();
143
 
144
        // Normalize validation keys so that we can use both indexed and
145
        // associative arrays.
146
        foreach ($validKeys as $key => $val) {
147
            is_int($key) ? $valids[$val] = null : $valids[$key] = $val;
148
        }
149
 
150
        $validKeys = array_keys($valids);
151
 
152
        // Check for invalid keys.
153
        foreach ($hash as $key => $value) {
154
            if (!in_array($key, $validKeys)) {
155
                $unknown[] = $key;
156
            }
157
        }
158
 
159
        if (!empty($unknown)) {
160
            throw new PHPUnit\Framework\Exception(
161
                'Unknown key(s): ' . implode(', ', $unknown)
162
            );
163
        }
164
 
165
        // Add default values for any valid keys that are empty.
166
        foreach ($valids as $key => $value) {
167
            if (!isset($hash[$key])) {
168
                $hash[$key] = $value;
169
            }
170
        }
171
 
172
        return $hash;
173
    }
174
 
175
    /**
176
     * Assert that two Date/Time strings are equal.
177
     *
178
     * The strings generated by \DateTime, \strtotime, \date, \time, etc. are generated outside of our control.
179
     * From time-to-time string changes are made.
180
     * One such example is from ICU 72.1 which changed the time format to include a narrow-non-breaking-space (U+202F)
181
     * between the time and AM/PM.
182
     *
183
     * We should not update our tests to match these changes, as it is not our code that is
184
     * generating the strings and they may change again.
185
     * In addition, the changes are not equal amongst all systems as they depend on the version of ICU installed.
186
     *
187
     * @param string $expected
188
     * @param string $actual
189
     * @param string $message
190
     */
191
    public function assertEqualsIgnoringWhitespace($expected, $actual, string $message = ''): void {
192
        // ICU 72.1 introduced the use of a narrow-non-breaking-space (U+202F) between the time and the AM/PM.
193
        // Normalise all whitespace when performing the comparison.
194
        $expected = preg_replace('/\s+/u', ' ', $expected);
195
        $actual = preg_replace('/\s+/u', ' ', $actual);
196
 
197
        $this->assertEquals($expected, $actual, $message);
198
    }
199
 
200
    /**
201
     * Parse out the options from the tag using DOM object tree.
202
     *
203
     * @param DOMDocument $dom
204
     * @param array       $options
205
     * @param bool        $isHtml
206
     *
207
     * @return array
208
     */
209
    public static function findNodes(DOMDocument $dom, array $options, $isHtml = true) {
210
        $valid = array(
211
            'id', 'class', 'tag', 'content', 'attributes', 'parent',
212
            'child', 'ancestor', 'descendant', 'children', 'adjacent-sibling'
213
        );
214
 
215
        $filtered = array();
216
        $options  = self::assertValidKeys($options, $valid);
217
 
218
        // find the element by id
219
        if ($options['id']) {
220
            $options['attributes']['id'] = $options['id'];
221
        }
222
 
223
        if ($options['class']) {
224
            $options['attributes']['class'] = $options['class'];
225
        }
226
 
227
        $nodes = array();
228
 
229
        // find the element by a tag type
230
        if ($options['tag']) {
231
            if ($isHtml) {
232
                $elements = self::getElementsByCaseInsensitiveTagName(
233
                    $dom,
234
                    $options['tag']
235
                );
236
            } else {
237
                $elements = $dom->getElementsByTagName($options['tag']);
238
            }
239
 
240
            foreach ($elements as $element) {
241
                $nodes[] = $element;
242
            }
243
 
244
            if (empty($nodes)) {
245
                return false;
246
            }
247
        } // no tag selected, get them all
248
        else {
249
            $tags = array(
250
                'a', 'abbr', 'acronym', 'address', 'area', 'b', 'base', 'bdo',
251
                'big', 'blockquote', 'body', 'br', 'button', 'caption', 'cite',
252
                'code', 'col', 'colgroup', 'dd', 'del', 'div', 'dfn', 'dl',
253
                'dt', 'em', 'fieldset', 'form', 'frame', 'frameset', 'h1', 'h2',
254
                'h3', 'h4', 'h5', 'h6', 'head', 'hr', 'html', 'i', 'iframe',
255
                'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'link',
256
                'map', 'meta', 'noframes', 'noscript', 'object', 'ol', 'optgroup',
257
                'option', 'p', 'param', 'pre', 'q', 'samp', 'script', 'select',
258
                'small', 'span', 'strong', 'style', 'sub', 'sup', 'table',
259
                'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'title',
260
                'tr', 'tt', 'ul', 'var',
261
                // HTML5
262
                'article', 'aside', 'audio', 'bdi', 'canvas', 'command',
263
                'datalist', 'details', 'dialog', 'embed', 'figure', 'figcaption',
264
                'footer', 'header', 'hgroup', 'keygen', 'mark', 'meter', 'nav',
265
                'output', 'progress', 'ruby', 'rt', 'rp', 'track', 'section',
266
                'source', 'summary', 'time', 'video', 'wbr'
267
            );
268
 
269
            foreach ($tags as $tag) {
270
                if ($isHtml) {
271
                    $elements = self::getElementsByCaseInsensitiveTagName(
272
                        $dom,
273
                        $tag
274
                    );
275
                } else {
276
                    $elements = $dom->getElementsByTagName($tag);
277
                }
278
 
279
                foreach ($elements as $element) {
280
                    $nodes[] = $element;
281
                }
282
            }
283
 
284
            if (empty($nodes)) {
285
                return false;
286
            }
287
        }
288
 
289
        // filter by attributes
290
        if ($options['attributes']) {
291
            foreach ($nodes as $node) {
292
                $invalid = false;
293
 
294
                foreach ($options['attributes'] as $name => $value) {
295
                    // match by regexp if like "regexp:/foo/i"
296
                    if (preg_match('/^regexp\s*:\s*(.*)/i', $value, $matches)) {
297
                        if (!preg_match($matches[1], $node->getAttribute($name))) {
298
                            $invalid = true;
299
                        }
300
                    } // class can match only a part
301
                    elseif ($name == 'class') {
302
                        // split to individual classes
303
                        $findClasses = explode(
304
                            ' ',
305
                            preg_replace("/\s+/", ' ', $value)
306
                        );
307
 
308
                        $allClasses = explode(
309
                            ' ',
310
                            preg_replace("/\s+/", ' ', $node->getAttribute($name))
311
                        );
312
 
313
                        // make sure each class given is in the actual node
314
                        foreach ($findClasses as $findClass) {
315
                            if (!in_array($findClass, $allClasses)) {
316
                                $invalid = true;
317
                            }
318
                        }
319
                    } // match by exact string
320
                    else {
321
                        if ($node->getAttribute($name) !== (string) $value) {
322
                            $invalid = true;
323
                        }
324
                    }
325
                }
326
 
327
                // if every attribute given matched
328
                if (!$invalid) {
329
                    $filtered[] = $node;
330
                }
331
            }
332
 
333
            $nodes    = $filtered;
334
            $filtered = array();
335
 
336
            if (empty($nodes)) {
337
                return false;
338
            }
339
        }
340
 
341
        // filter by content
342
        if ($options['content'] !== null) {
343
            foreach ($nodes as $node) {
344
                $invalid = false;
345
 
346
                // match by regexp if like "regexp:/foo/i"
347
                if (preg_match('/^regexp\s*:\s*(.*)/i', $options['content'], $matches)) {
348
                    if (!preg_match($matches[1], self::getNodeText($node))) {
349
                        $invalid = true;
350
                    }
351
                } // match empty string
352
                elseif ($options['content'] === '') {
353
                    if (self::getNodeText($node) !== '') {
354
                        $invalid = true;
355
                    }
356
                } // match by exact string
357
                elseif (strstr(self::getNodeText($node), $options['content']) === false) {
358
                    $invalid = true;
359
                }
360
 
361
                if (!$invalid) {
362
                    $filtered[] = $node;
363
                }
364
            }
365
 
366
            $nodes    = $filtered;
367
            $filtered = array();
368
 
369
            if (empty($nodes)) {
370
                return false;
371
            }
372
        }
373
 
374
        // filter by parent node
375
        if ($options['parent']) {
376
            $parentNodes = self::findNodes($dom, $options['parent'], $isHtml);
377
            $parentNode  = isset($parentNodes[0]) ? $parentNodes[0] : null;
378
 
379
            foreach ($nodes as $node) {
380
                if ($parentNode !== $node->parentNode) {
381
                    continue;
382
                }
383
 
384
                $filtered[] = $node;
385
            }
386
 
387
            $nodes    = $filtered;
388
            $filtered = array();
389
 
390
            if (empty($nodes)) {
391
                return false;
392
            }
393
        }
394
 
395
        // filter by child node
396
        if ($options['child']) {
397
            $childNodes = self::findNodes($dom, $options['child'], $isHtml);
398
            $childNodes = !empty($childNodes) ? $childNodes : array();
399
 
400
            foreach ($nodes as $node) {
401
                foreach ($node->childNodes as $child) {
402
                    foreach ($childNodes as $childNode) {
403
                        if ($childNode === $child) {
404
                            $filtered[] = $node;
405
                        }
406
                    }
407
                }
408
            }
409
 
410
            $nodes    = $filtered;
411
            $filtered = array();
412
 
413
            if (empty($nodes)) {
414
                return false;
415
            }
416
        }
417
 
418
        // filter by adjacent-sibling
419
        if ($options['adjacent-sibling']) {
420
            $adjacentSiblingNodes = self::findNodes($dom, $options['adjacent-sibling'], $isHtml);
421
            $adjacentSiblingNodes = !empty($adjacentSiblingNodes) ? $adjacentSiblingNodes : array();
422
 
423
            foreach ($nodes as $node) {
424
                $sibling = $node;
425
 
426
                while ($sibling = $sibling->nextSibling) {
427
                    if ($sibling->nodeType !== XML_ELEMENT_NODE) {
428
                        continue;
429
                    }
430
 
431
                    foreach ($adjacentSiblingNodes as $adjacentSiblingNode) {
432
                        if ($sibling === $adjacentSiblingNode) {
433
                            $filtered[] = $node;
434
                            break;
435
                        }
436
                    }
437
 
438
                    break;
439
                }
440
            }
441
 
442
            $nodes    = $filtered;
443
            $filtered = array();
444
 
445
            if (empty($nodes)) {
446
                return false;
447
            }
448
        }
449
 
450
        // filter by ancestor
451
        if ($options['ancestor']) {
452
            $ancestorNodes = self::findNodes($dom, $options['ancestor'], $isHtml);
453
            $ancestorNode  = isset($ancestorNodes[0]) ? $ancestorNodes[0] : null;
454
 
455
            foreach ($nodes as $node) {
456
                $parent = $node->parentNode;
457
 
458
                while ($parent && $parent->nodeType != XML_HTML_DOCUMENT_NODE) {
459
                    if ($parent === $ancestorNode) {
460
                        $filtered[] = $node;
461
                    }
462
 
463
                    $parent = $parent->parentNode;
464
                }
465
            }
466
 
467
            $nodes    = $filtered;
468
            $filtered = array();
469
 
470
            if (empty($nodes)) {
471
                return false;
472
            }
473
        }
474
 
475
        // filter by descendant
476
        if ($options['descendant']) {
477
            $descendantNodes = self::findNodes($dom, $options['descendant'], $isHtml);
478
            $descendantNodes = !empty($descendantNodes) ? $descendantNodes : array();
479
 
480
            foreach ($nodes as $node) {
481
                foreach (self::getDescendants($node) as $descendant) {
482
                    foreach ($descendantNodes as $descendantNode) {
483
                        if ($descendantNode === $descendant) {
484
                            $filtered[] = $node;
485
                        }
486
                    }
487
                }
488
            }
489
 
490
            $nodes    = $filtered;
491
            $filtered = array();
492
 
493
            if (empty($nodes)) {
494
                return false;
495
            }
496
        }
497
 
498
        // filter by children
499
        if ($options['children']) {
500
            $validChild   = array('count', 'greater_than', 'less_than', 'only');
501
            $childOptions = self::assertValidKeys(
502
                $options['children'],
503
                $validChild
504
            );
505
 
506
            foreach ($nodes as $node) {
507
                $childNodes = $node->childNodes;
508
 
509
                foreach ($childNodes as $childNode) {
510
                    if ($childNode->nodeType !== XML_CDATA_SECTION_NODE &&
511
                        $childNode->nodeType !== XML_TEXT_NODE) {
512
                        $children[] = $childNode;
513
                    }
514
                }
515
 
516
                // we must have children to pass this filter
517
                if (!empty($children)) {
518
                    // exact count of children
519
                    if ($childOptions['count'] !== null) {
520
                        if (count($children) !== $childOptions['count']) {
521
                            break;
522
                        }
523
                    } // range count of children
524
                    elseif ($childOptions['less_than']    !== null &&
525
                        $childOptions['greater_than'] !== null) {
526
                        if (count($children) >= $childOptions['less_than'] ||
527
                            count($children) <= $childOptions['greater_than']) {
528
                            break;
529
                        }
530
                    } // less than a given count
531
                    elseif ($childOptions['less_than'] !== null) {
532
                        if (count($children) >= $childOptions['less_than']) {
533
                            break;
534
                        }
535
                    } // more than a given count
536
                    elseif ($childOptions['greater_than'] !== null) {
537
                        if (count($children) <= $childOptions['greater_than']) {
538
                            break;
539
                        }
540
                    }
541
 
542
                    // match each child against a specific tag
543
                    if ($childOptions['only']) {
544
                        $onlyNodes = self::findNodes(
545
                            $dom,
546
                            $childOptions['only'],
547
                            $isHtml
548
                        );
549
 
550
                        // try to match each child to one of the 'only' nodes
551
                        foreach ($children as $child) {
552
                            $matched = false;
553
 
554
                            foreach ($onlyNodes as $onlyNode) {
555
                                if ($onlyNode === $child) {
556
                                    $matched = true;
557
                                }
558
                            }
559
 
560
                            if (!$matched) {
561
                                break 2;
562
                            }
563
                        }
564
                    }
565
 
566
                    $filtered[] = $node;
567
                }
568
            }
569
 
570
            $nodes = $filtered;
571
 
572
            if (empty($nodes)) {
573
                return;
574
            }
575
        }
576
 
577
        // return the first node that matches all criteria
578
        return !empty($nodes) ? $nodes : array();
579
    }
580
 
581
    /**
582
     * Recursively get flat array of all descendants of this node.
583
     *
584
     * @param DOMNode $node
585
     *
586
     * @return array
587
     */
588
    protected static function getDescendants(DOMNode $node) {
589
        $allChildren = array();
590
        $childNodes  = $node->childNodes ? $node->childNodes : array();
591
 
592
        foreach ($childNodes as $child) {
593
            if ($child->nodeType === XML_CDATA_SECTION_NODE ||
594
                $child->nodeType === XML_TEXT_NODE) {
595
                continue;
596
            }
597
 
598
            $children    = self::getDescendants($child);
599
            $allChildren = array_merge($allChildren, $children, array($child));
600
        }
601
 
602
        return isset($allChildren) ? $allChildren : array();
603
    }
604
 
605
    /**
606
     * Gets elements by case insensitive tagname.
607
     *
608
     * @param DOMDocument $dom
609
     * @param string      $tag
610
     *
611
     * @return DOMNodeList
612
     */
613
    protected static function getElementsByCaseInsensitiveTagName(DOMDocument $dom, $tag) {
614
        $elements = $dom->getElementsByTagName(strtolower($tag));
615
 
616
        if ($elements->length == 0) {
617
            $elements = $dom->getElementsByTagName(strtoupper($tag));
618
        }
619
 
620
        return $elements;
621
    }
622
 
623
    /**
624
     * Get the text value of this node's child text node.
625
     *
626
     * @param DOMNode $node
627
     *
628
     * @return string
629
     */
630
    protected static function getNodeText(DOMNode $node) {
631
        if (!$node->childNodes instanceof DOMNodeList) {
632
            return '';
633
        }
634
 
635
        $result = '';
636
 
637
        foreach ($node->childNodes as $childNode) {
638
            if ($childNode->nodeType === XML_TEXT_NODE ||
639
                $childNode->nodeType === XML_CDATA_SECTION_NODE) {
640
                $result .= trim($childNode->data) . ' ';
641
            } else {
642
                $result .= self::getNodeText($childNode);
643
            }
644
        }
645
 
646
        return str_replace('  ', ' ', $result);
647
    }
1441 ariadna 648
 
649
    /**
650
     * Helper to get the count of invocation.
651
     *
652
     * This is required because the method to use changed names in PHPUnit 10.0 in a breaking change.
653
     *
654
     * @param \PHPUnit\Framework\MockObject\Rule\InvocationOrder $counter
655
     * @return int
656
     */
657
    protected static function getInvocationCount(InvocationOrder $counter): int {
658
        if (method_exists($counter, 'numberOfInvocations')) {
659
            return $counter->numberOfInvocations();
660
        }
661
 
662
        return $counter->getInvocationCount();
663
    }
1 efrain 664
    // phpcs:enable
1441 ariadna 665
 
666
    /**
667
     * Get an invokable object for testing.
668
     *
669
     * This is a helper method to create an invokable object for testing which can be used to
670
     * track invocations, including arguments provided.
671
     *
672
     * This can be useful for modifications to the error handler.
673
     *
674
     * @return object
675
     */
676
    protected static function get_invokable() {
677
        return new class {
678
            private array $invocations = [];
679
            public function __invoke(...$args) {
680
                $this->invocations[] = $args;
681
            }
682
 
683
            public function get_invocations(): array {
684
                return $this->invocations;
685
            }
686
 
687
            public function get_invocation_count(): int {
688
                return count($this->invocations);
689
            }
690
 
691
            public function reset(): void {
692
                $this->invocations = [];
693
            }
694
        };
695
    }
696
 
697
    /**
698
     * Determine whether the test is running in isolation.
699
     *
700
     * Note: This was previously a public method of the TestCase, but as removed in PHPUnit 10.
701
     * There is no direct replacement, but we can use reflection to access the protected property.
702
     * @return bool
703
     */
704
    public function isInIsolation(): bool {
705
        $rc = new \ReflectionClass(TestCase::class);
706
        $rcp = $rc->getProperty('inIsolation');
707
 
708
        return $rcp->getValue($this);
709
    }
1 efrain 710
}