Merge branch 'MDL-31226-master' of https://github.com/lucisgit/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_behaviour(question_behaviour $behaviour) {
47         $this->behaviour = $behaviour;
48     }
49 }
52 /**
53  * Test subclass to allow access to some protected data so that the correct
54  * behaviour can be verified.
55  *
56  * @copyright  2012 The Open University
57  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
58  */
59 class testable_question_engine_unit_of_work extends question_engine_unit_of_work {
60     public function get_modified() {
61         return $this->modified;
62     }
64     public function get_attempts_added() {
65         return $this->attemptsadded;
66     }
68     public function get_attempts_modified() {
69         return $this->attemptsmodified;
70     }
72     public function get_steps_added() {
73         return $this->stepsadded;
74     }
76     public function get_steps_modified() {
77         return $this->stepsmodified;
78     }
80     public function get_steps_deleted() {
81         return $this->stepsdeleted;
82     }
83 }
86 /**
87  * Base class for question type test helpers.
88  *
89  * @copyright  2011 The Open University
90  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
91  */
92 abstract class question_test_helper {
93     /**
94      * @return array of example question names that can be passed as the $which
95      * argument of {@link test_question_maker::make_question} when $qtype is
96      * this question type.
97      */
98     abstract public function get_test_questions();
100     /**
101      * Set up a form to create a question in $cat. This method also sets cat and contextid on $questiondata object.
102      * @param object $cat the category
103      * @param object $questiondata form initialisation requires question data.
104      * @return moodleform
105      */
106     public static function get_question_editing_form($cat, $questiondata) {
107         $catcontext = context::instance_by_id($cat->contextid, MUST_EXIST);
108         $contexts = new question_edit_contexts($catcontext);
109         $dataforformconstructor = new stdClass();
110         $dataforformconstructor->qtype = $questiondata->qtype;
111         $dataforformconstructor->contextid = $questiondata->contextid = $catcontext->id;
112         $dataforformconstructor->category = $questiondata->category = $cat->id;
113         $dataforformconstructor->formoptions = new stdClass();
114         $dataforformconstructor->formoptions->canmove = true;
115         $dataforformconstructor->formoptions->cansaveasnew = true;
116         $dataforformconstructor->formoptions->movecontext = false;
117         $dataforformconstructor->formoptions->canedit = true;
118         $dataforformconstructor->formoptions->repeatelements = true;
119         $qtype = question_bank::get_qtype($questiondata->qtype);
120         return  $qtype->create_editing_form('question.php', $dataforformconstructor, $cat, $contexts, true);
121     }
125 /**
126  * This class creates questions of various types, which can then be used when
127  * testing.
128  *
129  * @copyright  2009 The Open University
130  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
131  */
132 class test_question_maker {
133     const STANDARD_OVERALL_CORRECT_FEEDBACK = 'Well done!';
134     const STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK =
135         'Parts, but only parts, of your response are correct.';
136     const STANDARD_OVERALL_INCORRECT_FEEDBACK = 'That is not right at all.';
138     /** @var array qtype => qtype test helper class. */
139     protected static $testhelpers = array();
141     /**
142      * Just make a question_attempt at a question. Useful for unit tests that
143      * need to pass a $qa to methods that call format_text. Probably not safe
144      * to use for anything beyond that.
145      * @param question_definition $question a question.
146      * @param number $maxmark the max mark to set.
147      * @return question_attempt the question attempt.
148      */
149     public static function get_a_qa($question, $maxmark = 3) {
150         return new question_attempt($question, 13, null, $maxmark);
151     }
153     /**
154      * Initialise the common fields of a question of any type.
155      */
156     public static function initialise_a_question($q) {
157         global $USER;
159         $q->id = 0;
160         $q->category = 0;
161         $q->parent = 0;
162         $q->questiontextformat = FORMAT_HTML;
163         $q->generalfeedbackformat = FORMAT_HTML;
164         $q->defaultmark = 1;
165         $q->penalty = 0.3333333;
166         $q->length = 1;
167         $q->stamp = make_unique_id_code();
168         $q->version = make_unique_id_code();
169         $q->hidden = 0;
170         $q->timecreated = time();
171         $q->timemodified = time();
172         $q->createdby = $USER->id;
173         $q->modifiedby = $USER->id;
174     }
176     public static function initialise_question_data($qdata) {
177         global $USER;
179         $qdata->id = 0;
180         $qdata->category = 0;
181         $qdata->contextid = 0;
182         $qdata->parent = 0;
183         $qdata->questiontextformat = FORMAT_HTML;
184         $qdata->generalfeedbackformat = FORMAT_HTML;
185         $qdata->defaultmark = 1;
186         $qdata->penalty = 0.3333333;
187         $qdata->length = 1;
188         $qdata->stamp = make_unique_id_code();
189         $qdata->version = make_unique_id_code();
190         $qdata->hidden = 0;
191         $qdata->timecreated = time();
192         $qdata->timemodified = time();
193         $qdata->createdby = $USER->id;
194         $qdata->modifiedby = $USER->id;
195         $qdata->hints = array();
196     }
198     /**
199      * Get the test helper class for a particular question type.
200      * @param $qtype the question type name, e.g. 'multichoice'.
201      * @return question_test_helper the test helper class.
202      */
203     public static function get_test_helper($qtype) {
204         global $CFG;
206         if (array_key_exists($qtype, self::$testhelpers)) {
207             return self::$testhelpers[$qtype];
208         }
210         $file = core_component::get_plugin_directory('qtype', $qtype) . '/tests/helper.php';
211         if (!is_readable($file)) {
212             throw new coding_exception('Question type ' . $qtype .
213                 ' does not have test helper code.');
214         }
215         include_once($file);
217         $class = 'qtype_' . $qtype . '_test_helper';
218         if (!class_exists($class)) {
219             throw new coding_exception('Class ' . $class . ' is not defined in ' . $file);
220         }
222         self::$testhelpers[$qtype] = new $class();
223         return self::$testhelpers[$qtype];
224     }
226     /**
227      * Call a method on a qtype_{$qtype}_test_helper class and return the result.
228      *
229      * @param string $methodtemplate e.g. 'make_{qtype}_question_{which}';
230      * @param string $qtype the question type to get a test question for.
231      * @param string $which one of the names returned by the get_test_questions
232      *      method of the relevant qtype_{$qtype}_test_helper class.
233      * @param unknown_type $which
234      */
235     protected static function call_question_helper_method($methodtemplate, $qtype, $which = null) {
236         $helper = self::get_test_helper($qtype);
238         $available = $helper->get_test_questions();
240         if (is_null($which)) {
241             $which = reset($available);
242         } else if (!in_array($which, $available)) {
243             throw new coding_exception('Example question ' . $which . ' of type ' .
244                 $qtype . ' does not exist.');
245         }
247         $method = str_replace(array('{qtype}', '{which}'),
248             array($qtype,    $which), $methodtemplate);
250         if (!method_exists($helper, $method)) {
251             throw new coding_exception('Method ' . $method . ' does not exist on the' .
252                 $qtype . ' question type test helper class.');
253         }
255         return $helper->$method();
256     }
258     /**
259      * Question types can provide a number of test question defintions.
260      * They do this by creating a qtype_{$qtype}_test_helper class that extends
261      * question_test_helper. The get_test_questions method returns the list of
262      * test questions available for this question type.
263      *
264      * @param string $qtype the question type to get a test question for.
265      * @param string $which one of the names returned by the get_test_questions
266      *      method of the relevant qtype_{$qtype}_test_helper class.
267      * @return question_definition the requested question object.
268      */
269     public static function make_question($qtype, $which = null) {
270         return self::call_question_helper_method('make_{qtype}_question_{which}',
271             $qtype, $which);
272     }
274     /**
275      * Like {@link make_question()} but returns the datastructure from
276      * get_question_options instead of the question_definition object.
277      *
278      * @param string $qtype the question type to get a test question for.
279      * @param string $which one of the names returned by the get_test_questions
280      *      method of the relevant qtype_{$qtype}_test_helper class.
281      * @return stdClass the requested question object.
282      */
283     public static function get_question_data($qtype, $which = null) {
284         return self::call_question_helper_method('get_{qtype}_question_data_{which}',
285             $qtype, $which);
286     }
288     /**
289      * Like {@link make_question()} but returns the data what would be saved from
290      * the question editing form instead of the question_definition object.
291      *
292      * @param string $qtype the question type to get a test question for.
293      * @param string $which one of the names returned by the get_test_questions
294      *      method of the relevant qtype_{$qtype}_test_helper class.
295      * @return stdClass the requested question object.
296      */
297     public static function get_question_form_data($qtype, $which = null) {
298         return self::call_question_helper_method('get_{qtype}_question_form_data_{which}',
299             $qtype, $which);
300     }
302     /**
303      * Makes a multichoice question with choices 'A', 'B' and 'C' shuffled. 'A'
304      * is correct, defaultmark 1.
305      * @return qtype_multichoice_single_question
306      */
307     public static function make_a_multichoice_single_question() {
308         question_bank::load_question_definition_classes('multichoice');
309         $mc = new qtype_multichoice_single_question();
310         self::initialise_a_question($mc);
311         $mc->name = 'Multi-choice question, single response';
312         $mc->questiontext = 'The answer is A.';
313         $mc->generalfeedback = 'You should have selected A.';
314         $mc->qtype = question_bank::get_qtype('multichoice');
316         $mc->shuffleanswers = 1;
317         $mc->answernumbering = 'abc';
319         $mc->answers = array(
320             13 => new question_answer(13, 'A', 1, 'A is right', FORMAT_HTML),
321             14 => new question_answer(14, 'B', -0.3333333, 'B is wrong', FORMAT_HTML),
322             15 => new question_answer(15, 'C', -0.3333333, 'C is wrong', FORMAT_HTML),
323         );
325         return $mc;
326     }
328     /**
329      * Makes a multichoice question with choices 'A', 'B', 'C' and 'D' shuffled.
330      * 'A' and 'C' is correct, defaultmark 1.
331      * @return qtype_multichoice_multi_question
332      */
333     public static function make_a_multichoice_multi_question() {
334         question_bank::load_question_definition_classes('multichoice');
335         $mc = new qtype_multichoice_multi_question();
336         self::initialise_a_question($mc);
337         $mc->name = 'Multi-choice question, multiple response';
338         $mc->questiontext = 'The answer is A and C.';
339         $mc->generalfeedback = 'You should have selected A and C.';
340         $mc->qtype = question_bank::get_qtype('multichoice');
342         $mc->shuffleanswers = 1;
343         $mc->answernumbering = 'abc';
345         self::set_standard_combined_feedback_fields($mc);
347         $mc->answers = array(
348             13 => new question_answer(13, 'A', 0.5, 'A is part of the right answer', FORMAT_HTML),
349             14 => new question_answer(14, 'B', -1, 'B is wrong', FORMAT_HTML),
350             15 => new question_answer(15, 'C', 0.5, 'C is part of the right answer', FORMAT_HTML),
351             16 => new question_answer(16, 'D', -1, 'D is wrong', FORMAT_HTML),
352         );
354         return $mc;
355     }
357     /**
358      * Makes a matching question to classify 'Dog', 'Frog', 'Toad' and 'Cat' as
359      * 'Mammal', 'Amphibian' or 'Insect'.
360      * defaultmark 1. Stems are shuffled by default.
361      * @return qtype_match_question
362      */
363     public static function make_a_matching_question() {
364         question_bank::load_question_definition_classes('match');
365         $match = new qtype_match_question();
366         self::initialise_a_question($match);
367         $match->name = 'Matching question';
368         $match->questiontext = 'Classify the animals.';
369         $match->generalfeedback = 'Frogs and toads are amphibians, the others are mammals.';
370         $match->qtype = question_bank::get_qtype('match');
372         $match->shufflestems = 1;
374         self::set_standard_combined_feedback_fields($match);
376         // Using unset to get 1-based arrays.
377         $match->stems = array('', 'Dog', 'Frog', 'Toad', 'Cat');
378         $match->stemformat = array('', FORMAT_HTML, FORMAT_HTML, FORMAT_HTML, FORMAT_HTML);
379         $match->choices = array('', 'Mammal', 'Amphibian', 'Insect');
380         $match->right = array('', 1, 2, 2, 1);
381         unset($match->stems[0]);
382         unset($match->stemformat[0]);
383         unset($match->choices[0]);
384         unset($match->right[0]);
386         return $match;
387     }
389     /**
390      * Makes a truefalse question with correct ansewer true, defaultmark 1.
391      * @return qtype_essay_question
392      */
393     public static function make_an_essay_question() {
394         question_bank::load_question_definition_classes('essay');
395         $essay = new qtype_essay_question();
396         self::initialise_a_question($essay);
397         $essay->name = 'Essay question';
398         $essay->questiontext = 'Write an essay.';
399         $essay->generalfeedback = 'I hope you wrote an interesting essay.';
400         $essay->penalty = 0;
401         $essay->qtype = question_bank::get_qtype('essay');
403         $essay->responseformat = 'editor';
404         $essay->responsefieldlines = 15;
405         $essay->attachments = 0;
406         $essay->graderinfo = '';
407         $essay->graderinfoformat = FORMAT_MOODLE;
409         return $essay;
410     }
412     /**
413      * Add some standard overall feedback to a question. You need to use these
414      * specific feedback strings for the corresponding contains_..._feedback
415      * methods in {@link qbehaviour_walkthrough_test_base} to works.
416      * @param question_definition $q the question to add the feedback to.
417      */
418     public static function set_standard_combined_feedback_fields($q) {
419         $q->correctfeedback = self::STANDARD_OVERALL_CORRECT_FEEDBACK;
420         $q->correctfeedbackformat = FORMAT_HTML;
421         $q->partiallycorrectfeedback = self::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK;
422         $q->partiallycorrectfeedbackformat = FORMAT_HTML;
423         $q->shownumcorrect = true;
424         $q->incorrectfeedback = self::STANDARD_OVERALL_INCORRECT_FEEDBACK;
425         $q->incorrectfeedbackformat = FORMAT_HTML;
426     }
428     /**
429      * Add some standard overall feedback to a question's form data.
430      */
431     public static function set_standard_combined_feedback_form_data($form) {
432         $form->correctfeedback = array('text' => self::STANDARD_OVERALL_CORRECT_FEEDBACK,
433                                     'format' => FORMAT_HTML);
434         $form->partiallycorrectfeedback = array('text' => self::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK,
435                                              'format' => FORMAT_HTML);
436         $form->shownumcorrect = true;
437         $form->incorrectfeedback = array('text' => self::STANDARD_OVERALL_INCORRECT_FEEDBACK,
438                                     'format' => FORMAT_HTML);
439     }
443 /**
444  * Helper for tests that need to simulate records loaded from the database.
445  *
446  * @copyright  2009 The Open University
447  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
448  */
449 abstract class testing_db_record_builder {
450     public static function build_db_records(array $table) {
451         $columns = array_shift($table);
452         $records = array();
453         foreach ($table as $row) {
454             if (count($row) != count($columns)) {
455                 throw new coding_exception("Row contains the wrong number of fields.");
456             }
457             $rec = new stdClass();
458             foreach ($columns as $i => $name) {
459                 $rec->$name = $row[$i];
460             }
461             $records[] = $rec;
462         }
463         return $records;
464     }
468 /**
469  * Helper base class for tests that need to simulate records loaded from the
470  * database.
471  *
472  * @copyright  2009 The Open University
473  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
474  */
475 abstract class data_loading_method_test_base extends advanced_testcase {
476     public function build_db_records(array $table) {
477         return testing_db_record_builder::build_db_records($table);
478     }
482 abstract class question_testcase extends advanced_testcase {
484     public function assert($expectation, $compare, $notused = '') {
486         if (get_class($expectation) === 'question_pattern_expectation') {
487             $this->assertRegExp($expectation->pattern, $compare,
488                     'Expected regex ' . $expectation->pattern . ' not found in ' . $compare);
489             return;
491         } else if (get_class($expectation) === 'question_no_pattern_expectation') {
492             $this->assertNotRegExp($expectation->pattern, $compare,
493                     'Unexpected regex ' . $expectation->pattern . ' found in ' . $compare);
494             return;
496         } else if (get_class($expectation) === 'question_contains_tag_with_attributes') {
497             $this->assertTag(array('tag'=>$expectation->tag, 'attributes'=>$expectation->expectedvalues), $compare,
498                     'Looking for a ' . $expectation->tag . ' with attributes ' . html_writer::attributes($expectation->expectedvalues) . ' in ' . $compare);
499             foreach ($expectation->forbiddenvalues as $k=>$v) {
500                 $attr = $expectation->expectedvalues;
501                 $attr[$k] = $v;
502                 $this->assertNotTag(array('tag'=>$expectation->tag, 'attributes'=>$attr), $compare,
503                         $expectation->tag . ' had a ' . $k . ' attribute that should not be there in ' . $compare);
504             }
505             return;
507         } else if (get_class($expectation) === 'question_contains_tag_with_attribute') {
508             $attr = array($expectation->attribute=>$expectation->value);
509             $this->assertTag(array('tag'=>$expectation->tag, 'attributes'=>$attr), $compare,
510                     'Looking for a ' . $expectation->tag . ' with attribute ' . html_writer::attributes($attr) . ' in ' . $compare);
511             return;
513         } else if (get_class($expectation) === 'question_does_not_contain_tag_with_attributes') {
514             $this->assertNotTag(array('tag'=>$expectation->tag, 'attributes'=>$expectation->attributes), $compare,
515                     'Unexpected ' . $expectation->tag . ' with attributes ' . html_writer::attributes($expectation->attributes) . ' found in ' . $compare);
516             return;
518         } else if (get_class($expectation) === 'question_contains_select_expectation') {
519             $tag = array('tag'=>'select', 'attributes'=>array('name'=>$expectation->name),
520                 'children'=>array('count'=>count($expectation->choices)));
521             if ($expectation->enabled === false) {
522                 $tag['attributes']['disabled'] = 'disabled';
523             } else if ($expectation->enabled === true) {
524                 // TODO
525             }
526             foreach(array_keys($expectation->choices) as $value) {
527                 if ($expectation->selected === $value) {
528                     $tag['child'] = array('tag'=>'option', 'attributes'=>array('value'=>$value, 'selected'=>'selected'));
529                 } else {
530                     $tag['child'] = array('tag'=>'option', 'attributes'=>array('value'=>$value));
531                 }
532             }
534             $this->assertTag($tag, $compare, 'expected select not found in ' . $compare);
535             return;
537         } else if (get_class($expectation) === 'question_check_specified_fields_expectation') {
538             $expect = (array)$expectation->expect;
539             $compare = (array)$compare;
540             foreach ($expect as $k=>$v) {
541                 if (!array_key_exists($k, $compare)) {
542                     $this->fail("Property $k does not exist");
543                 }
544                 if ($v != $compare[$k]) {
545                     $this->fail("Property $k is different");
546                 }
547             }
548             $this->assertTrue(true);
549             return;
551         } else if (get_class($expectation) === 'question_contains_tag_with_contents') {
552             $this->assertTag(array('tag'=>$expectation->tag, 'content'=>$expectation->content), $compare,
553                     'Looking for a ' . $expectation->tag . ' with content ' . $expectation->content . ' in ' . $compare);
554             return;
555         }
557         throw new coding_exception('Unknown expectiontion:'.get_class($expectation));
558     }
562 class question_contains_tag_with_contents {
563     public $tag;
564     public $content;
565     public $message;
567     public function __construct($tag, $content, $message = '') {
568         $this->tag = $tag;
569         $this->content = $content;
570         $this->message = $message;
571     }
575 class question_check_specified_fields_expectation {
576     public $expect;
577     public $message;
579     function __construct($expected, $message = '') {
580         $this->expect = $expected;
581         $this->message = $message;
582     }
586 class question_contains_select_expectation {
587     public $name;
588     public $choices;
589     public $selected;
590     public $enabled;
591     public $message;
593     public function __construct($name, $choices, $selected = null, $enabled = null, $message = '') {
594         $this->name = $name;
595         $this->choices = $choices;
596         $this->selected = $selected;
597         $this->enabled = $enabled;
598         $this->message = $message;
599     }
603 class question_does_not_contain_tag_with_attributes {
604     public $tag;
605     public $attributes;
606     public $message;
608     public function __construct($tag, $attributes, $message = '') {
609         $this->tag = $tag;
610         $this->attributes = $attributes;
611         $this->message = $message;
612     }
616 class question_contains_tag_with_attribute {
617     public $tag;
618     public $attribute;
619     public $value;
620     public $message;
622     public function __construct($tag, $attribute, $value, $message = '') {
623         $this->tag = $tag;
624         $this->attribute = $attribute;
625         $this->value = $value;
626         $this->message = $message;
627     }
631 class question_contains_tag_with_attributes {
632     public $tag;
633     public $expectedvalues = array();
634     public $forbiddenvalues = array();
635     public $message;
637     public function __construct($tag, $expectedvalues, $forbiddenvalues=array(), $message = '') {
638         $this->tag = $tag;
639         $this->expectedvalues = $expectedvalues;
640         $this->forbiddenvalues = $forbiddenvalues;
641         $this->message = $message;
642     }
646 class question_pattern_expectation {
647     public $pattern;
648     public $message;
650     public function __construct($pattern, $message = '') {
651         $this->pattern = $pattern;
652         $this->message = $message;
653     }
657 class question_no_pattern_expectation {
658     public $pattern;
659     public $message;
661     public function __construct($pattern, $message = '') {
662         $this->pattern = $pattern;
663         $this->message = $message;
664     }
668 /**
669  * Helper base class for tests that walk a question through a sequents of
670  * interactions under the control of a particular behaviour.
671  *
672  * @copyright  2009 The Open University
673  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
674  */
675 abstract class qbehaviour_walkthrough_test_base extends question_testcase {
676     /** @var question_display_options */
677     protected $displayoptions;
678     /** @var question_usage_by_activity */
679     protected $quba;
680     /** @var integer */
682     protected $slot;
683     /**
684      * @var string after {@link render()} has been called, this contains the
685      * display of the question in its current state.
686      */
687     protected $currentoutput = '';
689     protected function setUp() {
690         parent::setUp();
691         $this->resetAfterTest(true);
693         $this->displayoptions = new question_display_options();
694         $this->quba = question_engine::make_questions_usage_by_activity('unit_test',
695             context_system::instance());
696     }
698     protected function tearDown() {
699         $this->displayoptions = null;
700         $this->quba = null;
701         parent::tearDown();
702     }
704     protected function start_attempt_at_question($question, $preferredbehaviour,
705                                                  $maxmark = null, $variant = 1) {
706         $this->quba->set_preferred_behaviour($preferredbehaviour);
707         $this->slot = $this->quba->add_question($question, $maxmark);
708         $this->quba->start_question($this->slot, $variant);
709     }
711     /**
712      * Convert an array of data destined for one question to the equivalent POST data.
713      * @param array $data the data for the quetsion.
714      * @return array the complete post data.
715      */
716     protected function response_data_to_post($data) {
717         $prefix = $this->quba->get_field_prefix($this->slot);
718         $fulldata = array(
719             'slots' => $this->slot,
720             $prefix . ':sequencecheck' => $this->get_question_attempt()->get_sequence_check_count(),
721         );
722         foreach ($data as $name => $value) {
723             $fulldata[$prefix . $name] = $value;
724         }
725         return $fulldata;
726     }
728     protected function process_submission($data) {
729         // Backwards compatibility.
730         reset($data);
731         if (count($data) == 1 && key($data) === '-finish') {
732             $this->finish();
733         }
735         $this->quba->process_all_actions(time(), $this->response_data_to_post($data));
736     }
738     protected function process_autosave($data) {
739         $this->quba->process_all_autosaves(null, $this->response_data_to_post($data));
740     }
742     protected function finish() {
743         $this->quba->finish_all_questions();
744     }
746     protected function manual_grade($comment, $mark, $commentformat = null) {
747         $this->quba->manual_grade($this->slot, $comment, $mark, $commentformat);
748     }
750     protected function save_quba(moodle_database $db = null) {
751         question_engine::save_questions_usage_by_activity($this->quba, $db);
752     }
754     protected function load_quba(moodle_database $db = null) {
755         $this->quba = question_engine::load_questions_usage_by_activity($this->quba->get_id(), $db);
756     }
758     protected function delete_quba() {
759         question_engine::delete_questions_usage_by_activity($this->quba->get_id());
760         $this->quba = null;
761     }
763     protected function check_current_state($state) {
764         $this->assertEquals($state, $this->quba->get_question_state($this->slot),
765             'Questions is in the wrong state.');
766     }
768     protected function check_current_mark($mark) {
769         if (is_null($mark)) {
770             $this->assertNull($this->quba->get_question_mark($this->slot));
771         } else {
772             if ($mark == 0) {
773                 // PHP will think a null mark and a mark of 0 are equal,
774                 // so explicity check not null in this case.
775                 $this->assertNotNull($this->quba->get_question_mark($this->slot));
776             }
777             $this->assertEquals($mark, $this->quba->get_question_mark($this->slot),
778                 'Expected mark and actual mark differ.', 0.000001);
779         }
780     }
782     /**
783      * Generate the HTML rendering of the question in its current state in
784      * $this->currentoutput so that it can be verified.
785      */
786     protected function render() {
787         $this->currentoutput = $this->quba->render_question($this->slot, $this->displayoptions);
788     }
790     protected function check_output_contains_text_input($name, $value = null, $enabled = true) {
791         $attributes = array(
792             'type' => 'text',
793             'name' => $this->quba->get_field_prefix($this->slot) . $name,
794         );
795         if (!is_null($value)) {
796             $attributes['value'] = $value;
797         }
798         if (!$enabled) {
799             $attributes['readonly'] = 'readonly';
800         }
801         $matcher = $this->get_tag_matcher('input', $attributes);
802         $this->assertTag($matcher, $this->currentoutput,
803                 'Looking for an input with attributes ' . html_writer::attributes($attributes) . ' in ' . $this->currentoutput);
805         if ($enabled) {
806             $matcher['attributes']['readonly'] = 'readonly';
807             $this->assertNotTag($matcher, $this->currentoutput,
808                     'input with attributes ' . html_writer::attributes($attributes) .
809                     ' should not be read-only in ' . $this->currentoutput);
810         }
811     }
813     protected function check_output_contains_text_input_with_class($name, $class = null) {
814         $attributes = array(
815             'type' => 'text',
816             'name' => $this->quba->get_field_prefix($this->slot) . $name,
817         );
818         if (!is_null($class)) {
819             $attributes['class'] = 'regexp:/\b' . $class . '\b/';
820         }
822         $matcher = $this->get_tag_matcher('input', $attributes);
823         $this->assertTag($matcher, $this->currentoutput,
824                 'Looking for an input with attributes ' . html_writer::attributes($attributes) . ' in ' . $this->currentoutput);
825     }
827     protected function check_output_does_not_contain_text_input_with_class($name, $class = null) {
828         $attributes = array(
829             'type' => 'text',
830             'name' => $this->quba->get_field_prefix($this->slot) . $name,
831         );
832         if (!is_null($class)) {
833             $attributes['class'] = 'regexp:/\b' . $class . '\b/';
834         }
836         $matcher = $this->get_tag_matcher('input', $attributes);
837         $this->assertNotTag($matcher, $this->currentoutput,
838                 'Unexpected input with attributes ' . html_writer::attributes($attributes) . ' found in ' . $this->currentoutput);
839     }
841     protected function check_output_contains_hidden_input($name, $value) {
842         $attributes = array(
843             'type' => 'hidden',
844             'name' => $this->quba->get_field_prefix($this->slot) . $name,
845             'value' => $value,
846         );
847         $this->assertTag($this->get_tag_matcher('input', $attributes), $this->currentoutput,
848                 'Looking for a hidden input with attributes ' . html_writer::attributes($attributes) . ' in ' . $this->currentoutput);
849     }
851     protected function check_output_contains_lang_string($identifier, $component = '', $a = null) {
852         $this->render();
853         $string = get_string($identifier, $component, $a);
854         $this->assertContains($string, $this->currentoutput,
855                 'Expected string ' . $string . ' not found in ' . $this->currentoutput);
856     }
858     protected function get_tag_matcher($tag, $attributes) {
859         return array(
860             'tag' => $tag,
861             'attributes' => $attributes,
862         );
863     }
865     /**
866      * @param $condition one or more Expectations. (users varargs).
867      */
868     protected function check_current_output() {
869         $html = $this->quba->render_question($this->slot, $this->displayoptions);
870         foreach (func_get_args() as $condition) {
871             $this->assert($condition, $html);
872         }
873     }
875     protected function get_question_attempt() {
876         return $this->quba->get_question_attempt($this->slot);
877     }
879     protected function get_step_count() {
880         return $this->get_question_attempt()->get_num_steps();
881     }
883     protected function check_step_count($expectednumsteps) {
884         $this->assertEquals($expectednumsteps, $this->get_step_count());
885     }
887     protected function get_step($stepnum) {
888         return $this->get_question_attempt()->get_step($stepnum);
889     }
891     protected function get_contains_question_text_expectation($question) {
892         return new question_pattern_expectation('/' . preg_quote($question->questiontext, '/') . '/');
893     }
895     protected function get_contains_general_feedback_expectation($question) {
896         return new question_pattern_expectation('/' . preg_quote($question->generalfeedback, '/') . '/');
897     }
899     protected function get_does_not_contain_correctness_expectation() {
900         return new question_no_pattern_expectation('/class=\"correctness/');
901     }
903     protected function get_contains_correct_expectation() {
904         return new question_pattern_expectation('/' . preg_quote(get_string('correct', 'question'), '/') . '/');
905     }
907     protected function get_contains_partcorrect_expectation() {
908         return new question_pattern_expectation('/' .
909             preg_quote(get_string('partiallycorrect', 'question'), '/') . '/');
910     }
912     protected function get_contains_incorrect_expectation() {
913         return new question_pattern_expectation('/' . preg_quote(get_string('incorrect', 'question'), '/') . '/');
914     }
916     protected function get_contains_standard_correct_combined_feedback_expectation() {
917         return new question_pattern_expectation('/' .
918             preg_quote(test_question_maker::STANDARD_OVERALL_CORRECT_FEEDBACK, '/') . '/');
919     }
921     protected function get_contains_standard_partiallycorrect_combined_feedback_expectation() {
922         return new question_pattern_expectation('/' .
923             preg_quote(test_question_maker::STANDARD_OVERALL_PARTIALLYCORRECT_FEEDBACK, '/') . '/');
924     }
926     protected function get_contains_standard_incorrect_combined_feedback_expectation() {
927         return new question_pattern_expectation('/' .
928             preg_quote(test_question_maker::STANDARD_OVERALL_INCORRECT_FEEDBACK, '/') . '/');
929     }
931     protected function get_does_not_contain_feedback_expectation() {
932         return new question_no_pattern_expectation('/class="feedback"/');
933     }
935     protected function get_does_not_contain_num_parts_correct() {
936         return new question_no_pattern_expectation('/class="numpartscorrect"/');
937     }
939     protected function get_contains_num_parts_correct($num) {
940         $a = new stdClass();
941         $a->num = $num;
942         return new question_pattern_expectation('/<div class="numpartscorrect">' .
943             preg_quote(get_string('yougotnright', 'question', $a), '/') . '/');
944     }
946     protected function get_does_not_contain_specific_feedback_expectation() {
947         return new question_no_pattern_expectation('/class="specificfeedback"/');
948     }
950     protected function get_contains_validation_error_expectation() {
951         return new question_contains_tag_with_attribute('div', 'class', 'validationerror');
952     }
954     protected function get_does_not_contain_validation_error_expectation() {
955         return new question_no_pattern_expectation('/class="validationerror"/');
956     }
958     protected function get_contains_mark_summary($mark) {
959         $a = new stdClass();
960         $a->mark = format_float($mark, $this->displayoptions->markdp);
961         $a->max = format_float($this->quba->get_question_max_mark($this->slot),
962             $this->displayoptions->markdp);
963         return new question_pattern_expectation('/' .
964             preg_quote(get_string('markoutofmax', 'question', $a), '/') . '/');
965     }
967     protected function get_contains_marked_out_of_summary() {
968         $max = format_float($this->quba->get_question_max_mark($this->slot),
969             $this->displayoptions->markdp);
970         return new question_pattern_expectation('/' .
971             preg_quote(get_string('markedoutofmax', 'question', $max), '/') . '/');
972     }
974     protected function get_does_not_contain_mark_summary() {
975         return new question_no_pattern_expectation('/<div class="grade">/');
976     }
978     protected function get_contains_checkbox_expectation($baseattr, $enabled, $checked) {
979         $expectedattributes = $baseattr;
980         $forbiddenattributes = array();
981         $expectedattributes['type'] = 'checkbox';
982         if ($enabled === true) {
983             $forbiddenattributes['disabled'] = 'disabled';
984         } else if ($enabled === false) {
985             $expectedattributes['disabled'] = 'disabled';
986         }
987         if ($checked === true) {
988             $expectedattributes['checked'] = 'checked';
989         } else if ($checked === false) {
990             $forbiddenattributes['checked'] = 'checked';
991         }
992         return new question_contains_tag_with_attributes('input', $expectedattributes, $forbiddenattributes);
993     }
995     protected function get_contains_mc_checkbox_expectation($index, $enabled = null,
996                                                             $checked = null) {
997         return $this->get_contains_checkbox_expectation(array(
998             'name' => $this->quba->get_field_prefix($this->slot) . $index,
999             'value' => 1,
1000         ), $enabled, $checked);
1001     }
1003     protected function get_contains_radio_expectation($baseattr, $enabled, $checked) {
1004         $expectedattributes = $baseattr;
1005         $forbiddenattributes = array();
1006         $expectedattributes['type'] = 'radio';
1007         if ($enabled === true) {
1008             $forbiddenattributes['disabled'] = 'disabled';
1009         } else if ($enabled === false) {
1010             $expectedattributes['disabled'] = 'disabled';
1011         }
1012         if ($checked === true) {
1013             $expectedattributes['checked'] = 'checked';
1014         } else if ($checked === false) {
1015             $forbiddenattributes['checked'] = 'checked';
1016         }
1017         return new question_contains_tag_with_attributes('input', $expectedattributes, $forbiddenattributes);
1018     }
1020     protected function get_contains_mc_radio_expectation($index, $enabled = null, $checked = null) {
1021         return $this->get_contains_radio_expectation(array(
1022             'name' => $this->quba->get_field_prefix($this->slot) . 'answer',
1023             'value' => $index,
1024         ), $enabled, $checked);
1025     }
1027     protected function get_contains_hidden_expectation($name, $value = null) {
1028         $expectedattributes = array('type' => 'hidden', 'name' => s($name));
1029         if (!is_null($value)) {
1030             $expectedattributes['value'] = s($value);
1031         }
1032         return new question_contains_tag_with_attributes('input', $expectedattributes);
1033     }
1035     protected function get_does_not_contain_hidden_expectation($name, $value = null) {
1036         $expectedattributes = array('type' => 'hidden', 'name' => s($name));
1037         if (!is_null($value)) {
1038             $expectedattributes['value'] = s($value);
1039         }
1040         return new question_does_not_contain_tag_with_attributes('input', $expectedattributes);
1041     }
1043     protected function get_contains_tf_true_radio_expectation($enabled = null, $checked = null) {
1044         return $this->get_contains_radio_expectation(array(
1045             'name' => $this->quba->get_field_prefix($this->slot) . 'answer',
1046             'value' => 1,
1047         ), $enabled, $checked);
1048     }
1050     protected function get_contains_tf_false_radio_expectation($enabled = null, $checked = null) {
1051         return $this->get_contains_radio_expectation(array(
1052             'name' => $this->quba->get_field_prefix($this->slot) . 'answer',
1053             'value' => 0,
1054         ), $enabled, $checked);
1055     }
1057     protected function get_contains_cbm_radio_expectation($certainty, $enabled = null,
1058                                                           $checked = null) {
1059         return $this->get_contains_radio_expectation(array(
1060             'name' => $this->quba->get_field_prefix($this->slot) . '-certainty',
1061             'value' => $certainty,
1062         ), $enabled, $checked);
1063     }
1065     protected function get_contains_button_expectation($name, $value = null, $enabled = null) {
1066         $expectedattributes = array(
1067             'type' => 'submit',
1068             'name' => $name,
1069         );
1070         $forbiddenattributes = array();
1071         if (!is_null($value)) {
1072             $expectedattributes['value'] = $value;
1073         }
1074         if ($enabled === true) {
1075             $forbiddenattributes['disabled'] = 'disabled';
1076         } else if ($enabled === false) {
1077             $expectedattributes['disabled'] = 'disabled';
1078         }
1079         return new question_contains_tag_with_attributes('input', $expectedattributes, $forbiddenattributes);
1080     }
1082     protected function get_contains_submit_button_expectation($enabled = null) {
1083         return $this->get_contains_button_expectation(
1084             $this->quba->get_field_prefix($this->slot) . '-submit', null, $enabled);
1085     }
1087     protected function get_tries_remaining_expectation($n) {
1088         return new question_pattern_expectation('/' .
1089             preg_quote(get_string('triesremaining', 'qbehaviour_interactive', $n), '/') . '/');
1090     }
1092     protected function get_invalid_answer_expectation() {
1093         return new question_pattern_expectation('/' .
1094             preg_quote(get_string('invalidanswer', 'question'), '/') . '/');
1095     }
1097     protected function get_contains_try_again_button_expectation($enabled = null) {
1098         $expectedattributes = array(
1099             'type' => 'submit',
1100             'name' => $this->quba->get_field_prefix($this->slot) . '-tryagain',
1101         );
1102         $forbiddenattributes = array();
1103         if ($enabled === true) {
1104             $forbiddenattributes['disabled'] = 'disabled';
1105         } else if ($enabled === false) {
1106             $expectedattributes['disabled'] = 'disabled';
1107         }
1108         return new question_contains_tag_with_attributes('input', $expectedattributes, $forbiddenattributes);
1109     }
1111     protected function get_does_not_contain_try_again_button_expectation() {
1112         return new question_no_pattern_expectation('/name="' .
1113             $this->quba->get_field_prefix($this->slot) . '-tryagain"/');
1114     }
1116     protected function get_contains_select_expectation($name, $choices,
1117                                                        $selected = null, $enabled = null) {
1118         $fullname = $this->quba->get_field_prefix($this->slot) . $name;
1119         return new question_contains_select_expectation($fullname, $choices, $selected, $enabled);
1120     }
1122     protected function get_mc_right_answer_index($mc) {
1123         $order = $mc->get_order($this->get_question_attempt());
1124         foreach ($order as $i => $ansid) {
1125             if ($mc->answers[$ansid]->fraction == 1) {
1126                 return $i;
1127             }
1128         }
1129         $this->fail('This multiple choice question does not seem to have a right answer!');
1130     }
1132     protected function get_no_hint_visible_expectation() {
1133         return new question_no_pattern_expectation('/class="hint"/');
1134     }
1136     protected function get_contains_hint_expectation($hinttext) {
1137         // Does not currently verify hint text.
1138         return new question_contains_tag_with_attribute('div', 'class', 'hint');
1139     }
1142 /**
1143  * Simple class that implements the {@link moodle_recordset} API based on an
1144  * array of test data.
1145  *
1146  *  See the {@link question_attempt_step_db_test} class in
1147  *  question/engine/tests/testquestionattemptstep.php for an example of how
1148  *  this is used.
1149  *
1150  * @copyright  2011 The Open University
1151  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1152  */
1153 class question_test_recordset extends moodle_recordset {
1154     protected $records;
1156     /**
1157      * Constructor
1158      * @param $table as for {@link testing_db_record_builder::build_db_records()}
1159      *      but does not need a unique first column.
1160      */
1161     public function __construct(array $table) {
1162         $columns = array_shift($table);
1163         $this->records = array();
1164         foreach ($table as $row) {
1165             if (count($row) != count($columns)) {
1166                 throw new coding_exception("Row contains the wrong number of fields.");
1167             }
1168             $rec = array();
1169             foreach ($columns as $i => $name) {
1170                 $rec[$name] = $row[$i];
1171             }
1172             $this->records[] = $rec;
1173         }
1174         reset($this->records);
1175     }
1177     public function __destruct() {
1178         $this->close();
1179     }
1181     public function current() {
1182         return (object) current($this->records);
1183     }
1185     public function key() {
1186         if (is_null(key($this->records))) {
1187             return false;
1188         }
1189         $current = current($this->records);
1190         return reset($current);
1191     }
1193     public function next() {
1194         next($this->records);
1195     }
1197     public function valid() {
1198         return !is_null(key($this->records));
1199     }
1201     public function close() {
1202         $this->records = null;
1203     }
1206 /**
1207  * A {@link question_variant_selection_strategy} designed for testing.
1208  * For selected. questions it wil return a specific variants. In for the other
1209  * slots it will use a fallback strategy.
1210  *
1211  * @copyright  2013 The Open University
1212  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1213  */
1214 class question_variant_forced_choices_selection_strategy
1215     implements question_variant_selection_strategy {
1217     /** @var array seed => variant to select. */
1218     protected $forcedchoices;
1220     /** @var question_variant_selection_strategy strategy used to make the non-forced choices. */
1221     protected $basestrategy;
1223     /**
1224      * Constructor.
1225      * @param array $forcedchoice array seed => variant to select.
1226      * @param question_variant_selection_strategy $basestrategy strategy used
1227      *      to make the non-forced choices.
1228      */
1229     public function __construct(array $forcedchoices, question_variant_selection_strategy $basestrategy) {
1230         $this->forcedchoices = $forcedchoices;
1231         $this->basestrategy  = $basestrategy;
1232     }
1234     public function choose_variant($maxvariants, $seed) {
1235         if (array_key_exists($seed, $this->forcedchoices)) {
1236             if ($this->forcedchoices[$seed] > $maxvariants) {
1237                 throw new coding_exception('Forced variant out of range.');
1238             }
1239             return $this->forcedchoices[$seed];
1240         } else {
1241             return $this->basestrategy->choose_variant($maxvariants, $seed);
1242         }
1243     }
1245     /**
1246      * Helper method for preparing the $forcedchoices array.
1247      * @param array $variantsbyslot slot number => variant to select.
1248      * @param question_usage_by_activity $quba the question usage we need a strategy for.
1249      * @return array that can be passed to the constructor as $forcedchoices.
1250      */
1251     public static function prepare_forced_choices_array(array $variantsbyslot,
1252                                                         question_usage_by_activity $quba) {
1254         $forcedchoices = array();
1256         foreach ($variantsbyslot as $slot => $varianttochoose) {
1257             $question = $quba->get_question($slot);
1258             $seed = $question->get_variants_selection_seed();
1259             if (array_key_exists($seed, $forcedchoices) && $forcedchoices[$seed] != $varianttochoose) {
1260                 throw new coding_exception('Inconsistent forced variant detected at slot ' . $slot);
1261             }
1262             if ($varianttochoose > $question->get_num_variants()) {
1263                 throw new coding_exception('Forced variant out of range at slot ' . $slot);
1264             }
1265             $forcedchoices[$seed] = $varianttochoose;
1266         }
1267         return $forcedchoices;
1268     }