Merge branch 'MDL-43874' of git://github.com/timhunt/moodle
[moodle.git] / question / engine / tests / helpers.php
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/>.
17 /**
18  * This file contains helper classes for testing the question engine.
19  *
20  * @package    moodlecore
21  * @subpackage questionengine
22  * @copyright  2009 The Open University
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
27 defined('MOODLE_INTERNAL') || die();
29 global $CFG;
30 require_once(dirname(__FILE__) . '/../lib.php');
33 /**
34  * Makes some protected methods of question_attempt public to facilitate testing.
35  *
36  * @copyright  2009 The Open University
37  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38  */
39 class testable_question_attempt extends question_attempt {
40     public function add_step(question_attempt_step $step) {
41         parent::add_step($step);
42     }
43     public function set_min_fraction($fraction) {
44         $this->minfraction = $fraction;
45     }
46     public function set_max_fraction($fraction) {
47         $this->maxfraction = $fraction;
48     }
49     public function set_behaviour(question_behaviour $behaviour) {
50         $this->behaviour = $behaviour;
51     }
52 }
55 /**
56  * Test subclass to allow access to some protected data so that the correct
57  * behaviour can be verified.
58  *
59  * @copyright  2012 The Open University
60  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
61  */
62 class testable_question_engine_unit_of_work extends question_engine_unit_of_work {
63     public function get_modified() {
64         return $this->modified;
65     }
67     public function get_attempts_added() {
68         return $this->attemptsadded;
69     }
71     public function get_attempts_modified() {
72         return $this->attemptsmodified;
73     }
75     public function get_steps_added() {
76         return $this->stepsadded;
77     }
79     public function get_steps_modified() {
80         return $this->stepsmodified;
81     }
83     public function get_steps_deleted() {
84         return $this->stepsdeleted;
85     }
86 }
89 /**
90  * Base class for question type test helpers.
91  *
92  * @copyright  2011 The Open University
93  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
94  */
95 abstract class question_test_helper {
96     /**
97      * @return array of example question names that can be passed as the $which
98      * argument of {@link test_question_maker::make_question} when $qtype is
99      * this question type.
100      */
101     abstract public function get_test_questions();
103     /**
104      * Set up a form to create a question in $cat. This method also sets cat and contextid on $questiondata object.
105      * @param object $cat the category
106      * @param object $questiondata form initialisation requires question data.
107      * @return moodleform
108      */
109     public static function get_question_editing_form($cat, $questiondata) {
110         $catcontext = context::instance_by_id($cat->contextid, MUST_EXIST);
111         $contexts = new question_edit_contexts($catcontext);
112         $dataforformconstructor = new stdClass();
113         $dataforformconstructor->qtype = $questiondata->qtype;
114         $dataforformconstructor->contextid = $questiondata->contextid = $catcontext->id;
115         $dataforformconstructor->category = $questiondata->category = $cat->id;
116         $dataforformconstructor->formoptions = new stdClass();
117         $dataforformconstructor->formoptions->canmove = true;
118         $dataforformconstructor->formoptions->cansaveasnew = true;
119         $dataforformconstructor->formoptions->canedit = true;
120         $dataforformconstructor->formoptions->repeatelements = true;
121         $qtype = question_bank::get_qtype($questiondata->qtype);
122         return  $qtype->create_editing_form('question.php', $dataforformconstructor, $cat, $contexts, true);
123     }
127 /**
128  * This class creates questions of various types, which can then be used when
129  * testing.
130  *
131  * @copyright  2009 The Open University
132  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
133  */
134 class test_question_maker {
135     const STANDARD_OVERALL_CORRECT_FEEDBACK = 'Well done!';
136     const STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK =
137         'Parts, but only parts, of your response are correct.';
138     const STANDARD_OVERALL_INCORRECT_FEEDBACK = 'That is not right at all.';
140     /** @var array qtype => qtype test helper class. */
141     protected static $testhelpers = array();
143     /**
144      * Just make a question_attempt at a question. Useful for unit tests that
145      * need to pass a $qa to methods that call format_text. Probably not safe
146      * to use for anything beyond that.
147      * @param question_definition $question a question.
148      * @param number $maxmark the max mark to set.
149      * @return question_attempt the question attempt.
150      */
151     public static function get_a_qa($question, $maxmark = 3) {
152         return new question_attempt($question, 13, null, $maxmark);
153     }
155     /**
156      * Initialise the common fields of a question of any type.
157      */
158     public static function initialise_a_question($q) {
159         global $USER;
161         $q->id = 0;
162         $q->category = 0;
163         $q->parent = 0;
164         $q->questiontextformat = FORMAT_HTML;
165         $q->generalfeedbackformat = FORMAT_HTML;
166         $q->defaultmark = 1;
167         $q->penalty = 0.3333333;
168         $q->length = 1;
169         $q->stamp = make_unique_id_code();
170         $q->version = make_unique_id_code();
171         $q->hidden = 0;
172         $q->timecreated = time();
173         $q->timemodified = time();
174         $q->createdby = $USER->id;
175         $q->modifiedby = $USER->id;
176     }
178     public static function initialise_question_data($qdata) {
179         global $USER;
181         $qdata->id = 0;
182         $qdata->category = 0;
183         $qdata->contextid = 0;
184         $qdata->parent = 0;
185         $qdata->questiontextformat = FORMAT_HTML;
186         $qdata->generalfeedbackformat = FORMAT_HTML;
187         $qdata->defaultmark = 1;
188         $qdata->penalty = 0.3333333;
189         $qdata->length = 1;
190         $qdata->stamp = make_unique_id_code();
191         $qdata->version = make_unique_id_code();
192         $qdata->hidden = 0;
193         $qdata->timecreated = time();
194         $qdata->timemodified = time();
195         $qdata->createdby = $USER->id;
196         $qdata->modifiedby = $USER->id;
197         $qdata->hints = array();
198     }
200     /**
201      * Get the test helper class for a particular question type.
202      * @param $qtype the question type name, e.g. 'multichoice'.
203      * @return question_test_helper the test helper class.
204      */
205     public static function get_test_helper($qtype) {
206         global $CFG;
208         if (array_key_exists($qtype, self::$testhelpers)) {
209             return self::$testhelpers[$qtype];
210         }
212         $file = core_component::get_plugin_directory('qtype', $qtype) . '/tests/helper.php';
213         if (!is_readable($file)) {
214             throw new coding_exception('Question type ' . $qtype .
215                 ' does not have test helper code.');
216         }
217         include_once($file);
219         $class = 'qtype_' . $qtype . '_test_helper';
220         if (!class_exists($class)) {
221             throw new coding_exception('Class ' . $class . ' is not defined in ' . $file);
222         }
224         self::$testhelpers[$qtype] = new $class();
225         return self::$testhelpers[$qtype];
226     }
228     /**
229      * Call a method on a qtype_{$qtype}_test_helper class and return the result.
230      *
231      * @param string $methodtemplate e.g. 'make_{qtype}_question_{which}';
232      * @param string $qtype the question type to get a test question for.
233      * @param string $which one of the names returned by the get_test_questions
234      *      method of the relevant qtype_{$qtype}_test_helper class.
235      * @param unknown_type $which
236      */
237     protected static function call_question_helper_method($methodtemplate, $qtype, $which = null) {
238         $helper = self::get_test_helper($qtype);
240         $available = $helper->get_test_questions();
242         if (is_null($which)) {
243             $which = reset($available);
244         } else if (!in_array($which, $available)) {
245             throw new coding_exception('Example question ' . $which . ' of type ' .
246                 $qtype . ' does not exist.');
247         }
249         $method = str_replace(array('{qtype}', '{which}'),
250             array($qtype,    $which), $methodtemplate);
252         if (!method_exists($helper, $method)) {
253             throw new coding_exception('Method ' . $method . ' does not exist on the' .
254                 $qtype . ' question type test helper class.');
255         }
257         return $helper->$method();
258     }
260     /**
261      * Question types can provide a number of test question defintions.
262      * They do this by creating a qtype_{$qtype}_test_helper class that extends
263      * question_test_helper. The get_test_questions method returns the list of
264      * test questions available for this question type.
265      *
266      * @param string $qtype the question type to get a test question for.
267      * @param string $which one of the names returned by the get_test_questions
268      *      method of the relevant qtype_{$qtype}_test_helper class.
269      * @return question_definition the requested question object.
270      */
271     public static function make_question($qtype, $which = null) {
272         return self::call_question_helper_method('make_{qtype}_question_{which}',
273             $qtype, $which);
274     }
276     /**
277      * Like {@link make_question()} but returns the datastructure from
278      * get_question_options instead of the question_definition object.
279      *
280      * @param string $qtype the question type to get a test question for.
281      * @param string $which one of the names returned by the get_test_questions
282      *      method of the relevant qtype_{$qtype}_test_helper class.
283      * @return stdClass the requested question object.
284      */
285     public static function get_question_data($qtype, $which = null) {
286         return self::call_question_helper_method('get_{qtype}_question_data_{which}',
287             $qtype, $which);
288     }
290     /**
291      * Like {@link make_question()} but returns the data what would be saved from
292      * the question editing form instead of the question_definition object.
293      *
294      * @param string $qtype the question type to get a test question for.
295      * @param string $which one of the names returned by the get_test_questions
296      *      method of the relevant qtype_{$qtype}_test_helper class.
297      * @return stdClass the requested question object.
298      */
299     public static function get_question_form_data($qtype, $which = null) {
300         return self::call_question_helper_method('get_{qtype}_question_form_data_{which}',
301             $qtype, $which);
302     }
304     /**
305      * Makes a multichoice question with choices 'A', 'B' and 'C' shuffled. 'A'
306      * is correct, defaultmark 1.
307      * @return qtype_multichoice_single_question
308      */
309     public static function make_a_multichoice_single_question() {
310         question_bank::load_question_definition_classes('multichoice');
311         $mc = new qtype_multichoice_single_question();
312         self::initialise_a_question($mc);
313         $mc->name = 'Multi-choice question, single response';
314         $mc->questiontext = 'The answer is A.';
315         $mc->generalfeedback = 'You should have selected A.';
316         $mc->qtype = question_bank::get_qtype('multichoice');
318         $mc->shuffleanswers = 1;
319         $mc->answernumbering = 'abc';
321         $mc->answers = array(
322             13 => new question_answer(13, 'A', 1, 'A is right', FORMAT_HTML),
323             14 => new question_answer(14, 'B', -0.3333333, 'B is wrong', FORMAT_HTML),
324             15 => new question_answer(15, 'C', -0.3333333, 'C is wrong', FORMAT_HTML),
325         );
327         return $mc;
328     }
330     /**
331      * Makes a multichoice question with choices 'A', 'B', 'C' and 'D' shuffled.
332      * 'A' and 'C' is correct, defaultmark 1.
333      * @return qtype_multichoice_multi_question
334      */
335     public static function make_a_multichoice_multi_question() {
336         question_bank::load_question_definition_classes('multichoice');
337         $mc = new qtype_multichoice_multi_question();
338         self::initialise_a_question($mc);
339         $mc->name = 'Multi-choice question, multiple response';
340         $mc->questiontext = 'The answer is A and C.';
341         $mc->generalfeedback = 'You should have selected A and C.';
342         $mc->qtype = question_bank::get_qtype('multichoice');
344         $mc->shuffleanswers = 1;
345         $mc->answernumbering = 'abc';
347         self::set_standard_combined_feedback_fields($mc);
349         $mc->answers = array(
350             13 => new question_answer(13, 'A', 0.5, 'A is part of the right answer', FORMAT_HTML),
351             14 => new question_answer(14, 'B', -1, 'B is wrong', FORMAT_HTML),
352             15 => new question_answer(15, 'C', 0.5, 'C is part of the right answer', FORMAT_HTML),
353             16 => new question_answer(16, 'D', -1, 'D is wrong', FORMAT_HTML),
354         );
356         return $mc;
357     }
359     /**
360      * Makes a matching question to classify 'Dog', 'Frog', 'Toad' and 'Cat' as
361      * 'Mammal', 'Amphibian' or 'Insect'.
362      * defaultmark 1. Stems are shuffled by default.
363      * @return qtype_match_question
364      */
365     public static function make_a_matching_question() {
366         question_bank::load_question_definition_classes('match');
367         $match = new qtype_match_question();
368         self::initialise_a_question($match);
369         $match->name = 'Matching question';
370         $match->questiontext = 'Classify the animals.';
371         $match->generalfeedback = 'Frogs and toads are amphibians, the others are mammals.';
372         $match->qtype = question_bank::get_qtype('match');
374         $match->shufflestems = 1;
376         self::set_standard_combined_feedback_fields($match);
378         // Using unset to get 1-based arrays.
379         $match->stems = array('', 'Dog', 'Frog', 'Toad', 'Cat');
380         $match->stemformat = array('', FORMAT_HTML, FORMAT_HTML, FORMAT_HTML, FORMAT_HTML);
381         $match->choices = array('', 'Mammal', 'Amphibian', 'Insect');
382         $match->right = array('', 1, 2, 2, 1);
383         unset($match->stems[0]);
384         unset($match->stemformat[0]);
385         unset($match->choices[0]);
386         unset($match->right[0]);
388         return $match;
389     }
391     /**
392      * Makes a truefalse question with correct ansewer true, defaultmark 1.
393      * @return qtype_essay_question
394      */
395     public static function make_an_essay_question() {
396         question_bank::load_question_definition_classes('essay');
397         $essay = new qtype_essay_question();
398         self::initialise_a_question($essay);
399         $essay->name = 'Essay question';
400         $essay->questiontext = 'Write an essay.';
401         $essay->generalfeedback = 'I hope you wrote an interesting essay.';
402         $essay->penalty = 0;
403         $essay->qtype = question_bank::get_qtype('essay');
405         $essay->responseformat = 'editor';
406         $essay->responserequired = 1;
407         $essay->responsefieldlines = 15;
408         $essay->attachments = 0;
409         $essay->attachmentsrequired = 0;
410         $essay->responsetemplate = '';
411         $essay->responsetemplateformat = FORMAT_MOODLE;
412         $essay->graderinfo = '';
413         $essay->graderinfoformat = FORMAT_MOODLE;
415         return $essay;
416     }
418     /**
419      * Add some standard overall feedback to a question. You need to use these
420      * specific feedback strings for the corresponding contains_..._feedback
421      * methods in {@link qbehaviour_walkthrough_test_base} to works.
422      * @param question_definition $q the question to add the feedback to.
423      */
424     public static function set_standard_combined_feedback_fields($q) {
425         $q->correctfeedback = self::STANDARD_OVERALL_CORRECT_FEEDBACK;
426         $q->correctfeedbackformat = FORMAT_HTML;
427         $q->partiallycorrectfeedback = self::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK;
428         $q->partiallycorrectfeedbackformat = FORMAT_HTML;
429         $q->shownumcorrect = true;
430         $q->incorrectfeedback = self::STANDARD_OVERALL_INCORRECT_FEEDBACK;
431         $q->incorrectfeedbackformat = FORMAT_HTML;
432     }
434     /**
435      * Add some standard overall feedback to a question's form data.
436      */
437     public static function set_standard_combined_feedback_form_data($form) {
438         $form->correctfeedback = array('text' => self::STANDARD_OVERALL_CORRECT_FEEDBACK,
439                                     'format' => FORMAT_HTML);
440         $form->partiallycorrectfeedback = array('text' => self::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK,
441                                              'format' => FORMAT_HTML);
442         $form->shownumcorrect = true;
443         $form->incorrectfeedback = array('text' => self::STANDARD_OVERALL_INCORRECT_FEEDBACK,
444                                     'format' => FORMAT_HTML);
445     }
449 /**
450  * Helper for tests that need to simulate records loaded from the database.
451  *
452  * @copyright  2009 The Open University
453  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
454  */
455 abstract class testing_db_record_builder {
456     public static function build_db_records(array $table) {
457         $columns = array_shift($table);
458         $records = array();
459         foreach ($table as $row) {
460             if (count($row) != count($columns)) {
461                 throw new coding_exception("Row contains the wrong number of fields.");
462             }
463             $rec = new stdClass();
464             foreach ($columns as $i => $name) {
465                 $rec->$name = $row[$i];
466             }
467             $records[] = $rec;
468         }
469         return $records;
470     }
474 /**
475  * Helper base class for tests that need to simulate records loaded from the
476  * database.
477  *
478  * @copyright  2009 The Open University
479  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
480  */
481 abstract class data_loading_method_test_base extends advanced_testcase {
482     public function build_db_records(array $table) {
483         return testing_db_record_builder::build_db_records($table);
484     }
488 abstract class question_testcase extends advanced_testcase {
490     public function assert($expectation, $compare, $notused = '') {
492         if (get_class($expectation) === 'question_pattern_expectation') {
493             $this->assertRegExp($expectation->pattern, $compare,
494                     'Expected regex ' . $expectation->pattern . ' not found in ' . $compare);
495             return;
497         } else if (get_class($expectation) === 'question_no_pattern_expectation') {
498             $this->assertNotRegExp($expectation->pattern, $compare,
499                     'Unexpected regex ' . $expectation->pattern . ' found in ' . $compare);
500             return;
502         } else if (get_class($expectation) === 'question_contains_tag_with_attributes') {
503             $this->assertTag(array('tag'=>$expectation->tag, 'attributes'=>$expectation->expectedvalues), $compare,
504                     'Looking for a ' . $expectation->tag . ' with attributes ' . html_writer::attributes($expectation->expectedvalues) . ' in ' . $compare);
505             foreach ($expectation->forbiddenvalues as $k=>$v) {
506                 $attr = $expectation->expectedvalues;
507                 $attr[$k] = $v;
508                 $this->assertNotTag(array('tag'=>$expectation->tag, 'attributes'=>$attr), $compare,
509                         $expectation->tag . ' had a ' . $k . ' attribute that should not be there in ' . $compare);
510             }
511             return;
513         } else if (get_class($expectation) === 'question_contains_tag_with_attribute') {
514             $attr = array($expectation->attribute=>$expectation->value);
515             $this->assertTag(array('tag'=>$expectation->tag, 'attributes'=>$attr), $compare,
516                     'Looking for a ' . $expectation->tag . ' with attribute ' . html_writer::attributes($attr) . ' in ' . $compare);
517             return;
519         } else if (get_class($expectation) === 'question_does_not_contain_tag_with_attributes') {
520             $this->assertNotTag(array('tag'=>$expectation->tag, 'attributes'=>$expectation->attributes), $compare,
521                     'Unexpected ' . $expectation->tag . ' with attributes ' . html_writer::attributes($expectation->attributes) . ' found in ' . $compare);
522             return;
524         } else if (get_class($expectation) === 'question_contains_select_expectation') {
525             $tag = array('tag'=>'select', 'attributes'=>array('name'=>$expectation->name),
526                 'children'=>array('count'=>count($expectation->choices)));
527             if ($expectation->enabled === false) {
528                 $tag['attributes']['disabled'] = 'disabled';
529             } else if ($expectation->enabled === true) {
530                 // TODO
531             }
532             foreach(array_keys($expectation->choices) as $value) {
533                 if ($expectation->selected === $value) {
534                     $tag['child'] = array('tag'=>'option', 'attributes'=>array('value'=>$value, 'selected'=>'selected'));
535                 } else {
536                     $tag['child'] = array('tag'=>'option', 'attributes'=>array('value'=>$value));
537                 }
538             }
540             $this->assertTag($tag, $compare, 'expected select not found in ' . $compare);
541             return;
543         } else if (get_class($expectation) === 'question_check_specified_fields_expectation') {
544             $expect = (array)$expectation->expect;
545             $compare = (array)$compare;
546             foreach ($expect as $k=>$v) {
547                 if (!array_key_exists($k, $compare)) {
548                     $this->fail("Property $k does not exist");
549                 }
550                 if ($v != $compare[$k]) {
551                     $this->fail("Property $k is different");
552                 }
553             }
554             $this->assertTrue(true);
555             return;
557         } else if (get_class($expectation) === 'question_contains_tag_with_contents') {
558             $this->assertTag(array('tag'=>$expectation->tag, 'content'=>$expectation->content), $compare,
559                     'Looking for a ' . $expectation->tag . ' with content ' . $expectation->content . ' in ' . $compare);
560             return;
561         }
563         throw new coding_exception('Unknown expectiontion:'.get_class($expectation));
564     }
568 class question_contains_tag_with_contents {
569     public $tag;
570     public $content;
571     public $message;
573     public function __construct($tag, $content, $message = '') {
574         $this->tag = $tag;
575         $this->content = $content;
576         $this->message = $message;
577     }
581 class question_check_specified_fields_expectation {
582     public $expect;
583     public $message;
585     function __construct($expected, $message = '') {
586         $this->expect = $expected;
587         $this->message = $message;
588     }
592 class question_contains_select_expectation {
593     public $name;
594     public $choices;
595     public $selected;
596     public $enabled;
597     public $message;
599     public function __construct($name, $choices, $selected = null, $enabled = null, $message = '') {
600         $this->name = $name;
601         $this->choices = $choices;
602         $this->selected = $selected;
603         $this->enabled = $enabled;
604         $this->message = $message;
605     }
609 class question_does_not_contain_tag_with_attributes {
610     public $tag;
611     public $attributes;
612     public $message;
614     public function __construct($tag, $attributes, $message = '') {
615         $this->tag = $tag;
616         $this->attributes = $attributes;
617         $this->message = $message;
618     }
622 class question_contains_tag_with_attribute {
623     public $tag;
624     public $attribute;
625     public $value;
626     public $message;
628     public function __construct($tag, $attribute, $value, $message = '') {
629         $this->tag = $tag;
630         $this->attribute = $attribute;
631         $this->value = $value;
632         $this->message = $message;
633     }
637 class question_contains_tag_with_attributes {
638     public $tag;
639     public $expectedvalues = array();
640     public $forbiddenvalues = array();
641     public $message;
643     public function __construct($tag, $expectedvalues, $forbiddenvalues=array(), $message = '') {
644         $this->tag = $tag;
645         $this->expectedvalues = $expectedvalues;
646         $this->forbiddenvalues = $forbiddenvalues;
647         $this->message = $message;
648     }
652 class question_pattern_expectation {
653     public $pattern;
654     public $message;
656     public function __construct($pattern, $message = '') {
657         $this->pattern = $pattern;
658         $this->message = $message;
659     }
663 class question_no_pattern_expectation {
664     public $pattern;
665     public $message;
667     public function __construct($pattern, $message = '') {
668         $this->pattern = $pattern;
669         $this->message = $message;
670     }
674 /**
675  * Helper base class for tests that walk a question through a sequents of
676  * interactions under the control of a particular behaviour.
677  *
678  * @copyright  2009 The Open University
679  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
680  */
681 abstract class qbehaviour_walkthrough_test_base extends question_testcase {
682     /** @var question_display_options */
683     protected $displayoptions;
684     /** @var question_usage_by_activity */
685     protected $quba;
686     /** @var integer */
688     protected $slot;
689     /**
690      * @var string after {@link render()} has been called, this contains the
691      * display of the question in its current state.
692      */
693     protected $currentoutput = '';
695     protected function setUp() {
696         parent::setUp();
697         $this->resetAfterTest(true);
699         $this->displayoptions = new question_display_options();
700         $this->quba = question_engine::make_questions_usage_by_activity('unit_test',
701             context_system::instance());
702     }
704     protected function tearDown() {
705         $this->displayoptions = null;
706         $this->quba = null;
707         parent::tearDown();
708     }
710     protected function start_attempt_at_question($question, $preferredbehaviour,
711                                                  $maxmark = null, $variant = 1) {
712         $this->quba->set_preferred_behaviour($preferredbehaviour);
713         $this->slot = $this->quba->add_question($question, $maxmark);
714         $this->quba->start_question($this->slot, $variant);
715     }
717     /**
718      * Convert an array of data destined for one question to the equivalent POST data.
719      * @param array $data the data for the quetsion.
720      * @return array the complete post data.
721      */
722     protected function response_data_to_post($data) {
723         $prefix = $this->quba->get_field_prefix($this->slot);
724         $fulldata = array(
725             'slots' => $this->slot,
726             $prefix . ':sequencecheck' => $this->get_question_attempt()->get_sequence_check_count(),
727         );
728         foreach ($data as $name => $value) {
729             $fulldata[$prefix . $name] = $value;
730         }
731         return $fulldata;
732     }
734     protected function process_submission($data) {
735         // Backwards compatibility.
736         reset($data);
737         if (count($data) == 1 && key($data) === '-finish') {
738             $this->finish();
739         }
741         $this->quba->process_all_actions(time(), $this->response_data_to_post($data));
742     }
744     protected function process_autosave($data) {
745         $this->quba->process_all_autosaves(null, $this->response_data_to_post($data));
746     }
748     protected function finish() {
749         $this->quba->finish_all_questions();
750     }
752     protected function manual_grade($comment, $mark, $commentformat = null) {
753         $this->quba->manual_grade($this->slot, $comment, $mark, $commentformat);
754     }
756     protected function save_quba(moodle_database $db = null) {
757         question_engine::save_questions_usage_by_activity($this->quba, $db);
758     }
760     protected function load_quba(moodle_database $db = null) {
761         $this->quba = question_engine::load_questions_usage_by_activity($this->quba->get_id(), $db);
762     }
764     protected function delete_quba() {
765         question_engine::delete_questions_usage_by_activity($this->quba->get_id());
766         $this->quba = null;
767     }
769     protected function check_current_state($state) {
770         $this->assertEquals($state, $this->quba->get_question_state($this->slot),
771             'Questions is in the wrong state.');
772     }
774     protected function check_current_mark($mark) {
775         if (is_null($mark)) {
776             $this->assertNull($this->quba->get_question_mark($this->slot));
777         } else {
778             if ($mark == 0) {
779                 // PHP will think a null mark and a mark of 0 are equal,
780                 // so explicity check not null in this case.
781                 $this->assertNotNull($this->quba->get_question_mark($this->slot));
782             }
783             $this->assertEquals($mark, $this->quba->get_question_mark($this->slot),
784                 'Expected mark and actual mark differ.', 0.000001);
785         }
786     }
788     /**
789      * Generate the HTML rendering of the question in its current state in
790      * $this->currentoutput so that it can be verified.
791      */
792     protected function render() {
793         $this->currentoutput = $this->quba->render_question($this->slot, $this->displayoptions);
794     }
796     protected function check_output_contains_text_input($name, $value = null, $enabled = true) {
797         $attributes = array(
798             'type' => 'text',
799             'name' => $this->quba->get_field_prefix($this->slot) . $name,
800         );
801         if (!is_null($value)) {
802             $attributes['value'] = $value;
803         }
804         if (!$enabled) {
805             $attributes['readonly'] = 'readonly';
806         }
807         $matcher = $this->get_tag_matcher('input', $attributes);
808         $this->assertTag($matcher, $this->currentoutput,
809                 'Looking for an input with attributes ' . html_writer::attributes($attributes) . ' in ' . $this->currentoutput);
811         if ($enabled) {
812             $matcher['attributes']['readonly'] = 'readonly';
813             $this->assertNotTag($matcher, $this->currentoutput,
814                     'input with attributes ' . html_writer::attributes($attributes) .
815                     ' should not be read-only in ' . $this->currentoutput);
816         }
817     }
819     protected function check_output_contains_text_input_with_class($name, $class = null) {
820         $attributes = array(
821             'type' => 'text',
822             'name' => $this->quba->get_field_prefix($this->slot) . $name,
823         );
824         if (!is_null($class)) {
825             $attributes['class'] = 'regexp:/\b' . $class . '\b/';
826         }
828         $matcher = $this->get_tag_matcher('input', $attributes);
829         $this->assertTag($matcher, $this->currentoutput,
830                 'Looking for an input with attributes ' . html_writer::attributes($attributes) . ' in ' . $this->currentoutput);
831     }
833     protected function check_output_does_not_contain_text_input_with_class($name, $class = null) {
834         $attributes = array(
835             'type' => 'text',
836             'name' => $this->quba->get_field_prefix($this->slot) . $name,
837         );
838         if (!is_null($class)) {
839             $attributes['class'] = 'regexp:/\b' . $class . '\b/';
840         }
842         $matcher = $this->get_tag_matcher('input', $attributes);
843         $this->assertNotTag($matcher, $this->currentoutput,
844                 'Unexpected input with attributes ' . html_writer::attributes($attributes) . ' found in ' . $this->currentoutput);
845     }
847     protected function check_output_contains_hidden_input($name, $value) {
848         $attributes = array(
849             'type' => 'hidden',
850             'name' => $this->quba->get_field_prefix($this->slot) . $name,
851             'value' => $value,
852         );
853         $this->assertTag($this->get_tag_matcher('input', $attributes), $this->currentoutput,
854                 'Looking for a hidden input with attributes ' . html_writer::attributes($attributes) . ' in ' . $this->currentoutput);
855     }
857     protected function check_output_contains($string) {
858         $this->render();
859         $this->assertContains($string, $this->currentoutput,
860                 'Expected string ' . $string . ' not found in ' . $this->currentoutput);
861     }
863     protected function check_output_does_not_contain($string) {
864         $this->render();
865         $this->assertNotContains($string, $this->currentoutput,
866                 'String ' . $string . ' unexpectedly found in ' . $this->currentoutput);
867     }
869     protected function check_output_contains_lang_string($identifier, $component = '', $a = null) {
870         $this->check_output_contains(get_string($identifier, $component, $a));
871     }
873     protected function get_tag_matcher($tag, $attributes) {
874         return array(
875             'tag' => $tag,
876             'attributes' => $attributes,
877         );
878     }
880     /**
881      * @param $condition one or more Expectations. (users varargs).
882      */
883     protected function check_current_output() {
884         $html = $this->quba->render_question($this->slot, $this->displayoptions);
885         foreach (func_get_args() as $condition) {
886             $this->assert($condition, $html);
887         }
888     }
890     protected function get_question_attempt() {
891         return $this->quba->get_question_attempt($this->slot);
892     }
894     protected function get_step_count() {
895         return $this->get_question_attempt()->get_num_steps();
896     }
898     protected function check_step_count($expectednumsteps) {
899         $this->assertEquals($expectednumsteps, $this->get_step_count());
900     }
902     protected function get_step($stepnum) {
903         return $this->get_question_attempt()->get_step($stepnum);
904     }
906     protected function get_contains_question_text_expectation($question) {
907         return new question_pattern_expectation('/' . preg_quote($question->questiontext, '/') . '/');
908     }
910     protected function get_contains_general_feedback_expectation($question) {
911         return new question_pattern_expectation('/' . preg_quote($question->generalfeedback, '/') . '/');
912     }
914     protected function get_does_not_contain_correctness_expectation() {
915         return new question_no_pattern_expectation('/class=\"correctness/');
916     }
918     protected function get_contains_correct_expectation() {
919         return new question_pattern_expectation('/' . preg_quote(get_string('correct', 'question'), '/') . '/');
920     }
922     protected function get_contains_partcorrect_expectation() {
923         return new question_pattern_expectation('/' .
924             preg_quote(get_string('partiallycorrect', 'question'), '/') . '/');
925     }
927     protected function get_contains_incorrect_expectation() {
928         return new question_pattern_expectation('/' . preg_quote(get_string('incorrect', 'question'), '/') . '/');
929     }
931     protected function get_contains_standard_correct_combined_feedback_expectation() {
932         return new question_pattern_expectation('/' .
933             preg_quote(test_question_maker::STANDARD_OVERALL_CORRECT_FEEDBACK, '/') . '/');
934     }
936     protected function get_contains_standard_partiallycorrect_combined_feedback_expectation() {
937         return new question_pattern_expectation('/' .
938             preg_quote(test_question_maker::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK, '/') . '/');
939     }
941     protected function get_contains_standard_incorrect_combined_feedback_expectation() {
942         return new question_pattern_expectation('/' .
943             preg_quote(test_question_maker::STANDARD_OVERALL_INCORRECT_FEEDBACK, '/') . '/');
944     }
946     protected function get_does_not_contain_feedback_expectation() {
947         return new question_no_pattern_expectation('/class="feedback"/');
948     }
950     protected function get_does_not_contain_num_parts_correct() {
951         return new question_no_pattern_expectation('/class="numpartscorrect"/');
952     }
954     protected function get_contains_num_parts_correct($num) {
955         $a = new stdClass();
956         $a->num = $num;
957         return new question_pattern_expectation('/<div class="numpartscorrect">' .
958             preg_quote(get_string('yougotnright', 'question', $a), '/') . '/');
959     }
961     protected function get_does_not_contain_specific_feedback_expectation() {
962         return new question_no_pattern_expectation('/class="specificfeedback"/');
963     }
965     protected function get_contains_validation_error_expectation() {
966         return new question_contains_tag_with_attribute('div', 'class', 'validationerror');
967     }
969     protected function get_does_not_contain_validation_error_expectation() {
970         return new question_no_pattern_expectation('/class="validationerror"/');
971     }
973     protected function get_contains_mark_summary($mark) {
974         $a = new stdClass();
975         $a->mark = format_float($mark, $this->displayoptions->markdp);
976         $a->max = format_float($this->quba->get_question_max_mark($this->slot),
977             $this->displayoptions->markdp);
978         return new question_pattern_expectation('/' .
979             preg_quote(get_string('markoutofmax', 'question', $a), '/') . '/');
980     }
982     protected function get_contains_marked_out_of_summary() {
983         $max = format_float($this->quba->get_question_max_mark($this->slot),
984             $this->displayoptions->markdp);
985         return new question_pattern_expectation('/' .
986             preg_quote(get_string('markedoutofmax', 'question', $max), '/') . '/');
987     }
989     protected function get_does_not_contain_mark_summary() {
990         return new question_no_pattern_expectation('/<div class="grade">/');
991     }
993     protected function get_contains_checkbox_expectation($baseattr, $enabled, $checked) {
994         $expectedattributes = $baseattr;
995         $forbiddenattributes = array();
996         $expectedattributes['type'] = 'checkbox';
997         if ($enabled === true) {
998             $forbiddenattributes['disabled'] = 'disabled';
999         } else if ($enabled === false) {
1000             $expectedattributes['disabled'] = 'disabled';
1001         }
1002         if ($checked === true) {
1003             $expectedattributes['checked'] = 'checked';
1004         } else if ($checked === false) {
1005             $forbiddenattributes['checked'] = 'checked';
1006         }
1007         return new question_contains_tag_with_attributes('input', $expectedattributes, $forbiddenattributes);
1008     }
1010     protected function get_contains_mc_checkbox_expectation($index, $enabled = null,
1011                                                             $checked = null) {
1012         return $this->get_contains_checkbox_expectation(array(
1013             'name' => $this->quba->get_field_prefix($this->slot) . $index,
1014             'value' => 1,
1015         ), $enabled, $checked);
1016     }
1018     protected function get_contains_radio_expectation($baseattr, $enabled, $checked) {
1019         $expectedattributes = $baseattr;
1020         $forbiddenattributes = array();
1021         $expectedattributes['type'] = 'radio';
1022         if ($enabled === true) {
1023             $forbiddenattributes['disabled'] = 'disabled';
1024         } else if ($enabled === false) {
1025             $expectedattributes['disabled'] = 'disabled';
1026         }
1027         if ($checked === true) {
1028             $expectedattributes['checked'] = 'checked';
1029         } else if ($checked === false) {
1030             $forbiddenattributes['checked'] = 'checked';
1031         }
1032         return new question_contains_tag_with_attributes('input', $expectedattributes, $forbiddenattributes);
1033     }
1035     protected function get_contains_mc_radio_expectation($index, $enabled = null, $checked = null) {
1036         return $this->get_contains_radio_expectation(array(
1037             'name' => $this->quba->get_field_prefix($this->slot) . 'answer',
1038             'value' => $index,
1039         ), $enabled, $checked);
1040     }
1042     protected function get_contains_hidden_expectation($name, $value = null) {
1043         $expectedattributes = array('type' => 'hidden', 'name' => s($name));
1044         if (!is_null($value)) {
1045             $expectedattributes['value'] = s($value);
1046         }
1047         return new question_contains_tag_with_attributes('input', $expectedattributes);
1048     }
1050     protected function get_does_not_contain_hidden_expectation($name, $value = null) {
1051         $expectedattributes = array('type' => 'hidden', 'name' => s($name));
1052         if (!is_null($value)) {
1053             $expectedattributes['value'] = s($value);
1054         }
1055         return new question_does_not_contain_tag_with_attributes('input', $expectedattributes);
1056     }
1058     protected function get_contains_tf_true_radio_expectation($enabled = null, $checked = null) {
1059         return $this->get_contains_radio_expectation(array(
1060             'name' => $this->quba->get_field_prefix($this->slot) . 'answer',
1061             'value' => 1,
1062         ), $enabled, $checked);
1063     }
1065     protected function get_contains_tf_false_radio_expectation($enabled = null, $checked = null) {
1066         return $this->get_contains_radio_expectation(array(
1067             'name' => $this->quba->get_field_prefix($this->slot) . 'answer',
1068             'value' => 0,
1069         ), $enabled, $checked);
1070     }
1072     protected function get_contains_cbm_radio_expectation($certainty, $enabled = null,
1073                                                           $checked = null) {
1074         return $this->get_contains_radio_expectation(array(
1075             'name' => $this->quba->get_field_prefix($this->slot) . '-certainty',
1076             'value' => $certainty,
1077         ), $enabled, $checked);
1078     }
1080     protected function get_contains_button_expectation($name, $value = null, $enabled = null) {
1081         $expectedattributes = array(
1082             'type' => 'submit',
1083             'name' => $name,
1084         );
1085         $forbiddenattributes = array();
1086         if (!is_null($value)) {
1087             $expectedattributes['value'] = $value;
1088         }
1089         if ($enabled === true) {
1090             $forbiddenattributes['disabled'] = 'disabled';
1091         } else if ($enabled === false) {
1092             $expectedattributes['disabled'] = 'disabled';
1093         }
1094         return new question_contains_tag_with_attributes('input', $expectedattributes, $forbiddenattributes);
1095     }
1097     protected function get_contains_submit_button_expectation($enabled = null) {
1098         return $this->get_contains_button_expectation(
1099             $this->quba->get_field_prefix($this->slot) . '-submit', null, $enabled);
1100     }
1102     protected function get_tries_remaining_expectation($n) {
1103         return new question_pattern_expectation('/' .
1104             preg_quote(get_string('triesremaining', 'qbehaviour_interactive', $n), '/') . '/');
1105     }
1107     protected function get_invalid_answer_expectation() {
1108         return new question_pattern_expectation('/' .
1109             preg_quote(get_string('invalidanswer', 'question'), '/') . '/');
1110     }
1112     protected function get_contains_try_again_button_expectation($enabled = null) {
1113         $expectedattributes = array(
1114             'type' => 'submit',
1115             'name' => $this->quba->get_field_prefix($this->slot) . '-tryagain',
1116         );
1117         $forbiddenattributes = array();
1118         if ($enabled === true) {
1119             $forbiddenattributes['disabled'] = 'disabled';
1120         } else if ($enabled === false) {
1121             $expectedattributes['disabled'] = 'disabled';
1122         }
1123         return new question_contains_tag_with_attributes('input', $expectedattributes, $forbiddenattributes);
1124     }
1126     protected function get_does_not_contain_try_again_button_expectation() {
1127         return new question_no_pattern_expectation('/name="' .
1128             $this->quba->get_field_prefix($this->slot) . '-tryagain"/');
1129     }
1131     protected function get_contains_select_expectation($name, $choices,
1132                                                        $selected = null, $enabled = null) {
1133         $fullname = $this->quba->get_field_prefix($this->slot) . $name;
1134         return new question_contains_select_expectation($fullname, $choices, $selected, $enabled);
1135     }
1137     protected function get_mc_right_answer_index($mc) {
1138         $order = $mc->get_order($this->get_question_attempt());
1139         foreach ($order as $i => $ansid) {
1140             if ($mc->answers[$ansid]->fraction == 1) {
1141                 return $i;
1142             }
1143         }
1144         $this->fail('This multiple choice question does not seem to have a right answer!');
1145     }
1147     protected function get_no_hint_visible_expectation() {
1148         return new question_no_pattern_expectation('/class="hint"/');
1149     }
1151     protected function get_contains_hint_expectation($hinttext) {
1152         // Does not currently verify hint text.
1153         return new question_contains_tag_with_attribute('div', 'class', 'hint');
1154     }
1157 /**
1158  * Simple class that implements the {@link moodle_recordset} API based on an
1159  * array of test data.
1160  *
1161  *  See the {@link question_attempt_step_db_test} class in
1162  *  question/engine/tests/testquestionattemptstep.php for an example of how
1163  *  this is used.
1164  *
1165  * @copyright  2011 The Open University
1166  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1167  */
1168 class question_test_recordset extends moodle_recordset {
1169     protected $records;
1171     /**
1172      * Constructor
1173      * @param $table as for {@link testing_db_record_builder::build_db_records()}
1174      *      but does not need a unique first column.
1175      */
1176     public function __construct(array $table) {
1177         $columns = array_shift($table);
1178         $this->records = array();
1179         foreach ($table as $row) {
1180             if (count($row) != count($columns)) {
1181                 throw new coding_exception("Row contains the wrong number of fields.");
1182             }
1183             $rec = array();
1184             foreach ($columns as $i => $name) {
1185                 $rec[$name] = $row[$i];
1186             }
1187             $this->records[] = $rec;
1188         }
1189         reset($this->records);
1190     }
1192     public function __destruct() {
1193         $this->close();
1194     }
1196     public function current() {
1197         return (object) current($this->records);
1198     }
1200     public function key() {
1201         if (is_null(key($this->records))) {
1202             return false;
1203         }
1204         $current = current($this->records);
1205         return reset($current);
1206     }
1208     public function next() {
1209         next($this->records);
1210     }
1212     public function valid() {
1213         return !is_null(key($this->records));
1214     }
1216     public function close() {
1217         $this->records = null;
1218     }