MDL-69798 XML question export: excape special chars in idnumbers
[moodle.git] / question / format / xml / tests / qformat_xml_import_export_test.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/>.
16 /**
17  * Unit tests for export/import description (info) for question category in the Moodle XML format.
18  *
19  * @package    qformat_xml
20  * @copyright  2014 Nikita Nikitsky, Volgograd State Technical University
21  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22  */
23 defined('MOODLE_INTERNAL') || die();
24 global $CFG;
25 require_once($CFG->libdir . '/questionlib.php');
26 require_once($CFG->dirroot . '/question/format/xml/format.php');
27 require_once($CFG->dirroot . '/question/format.php');
28 require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
29 require_once($CFG->dirroot . '/question/editlib.php');
31 /**
32  * Unit tests for the XML question format import and export.
33  *
34  * @copyright  2014 Nikita Nikitsky, Volgograd State Technical University
35  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36  */
37 class qformat_xml_import_export_test extends advanced_testcase {
38     /**
39      * Create object qformat_xml for test.
40      * @param string $filename with name for testing file.
41      * @param stdClass $course
42      * @return qformat_xml XML question format object.
43      */
44     public function create_qformat($filename, $course) {
45         global $DB;
46         $qformat = new qformat_xml();
47         $contexts = $DB->get_records('context');
48         $importfile = __DIR__ . '/fixtures/' .$filename;
49         $realfilename = $filename;
50         $qformat->setContexts($contexts);
51         $qformat->setCourse($course);
52         $qformat->setFilename($importfile);
53         $qformat->setRealfilename($realfilename);
54         $qformat->setMatchgrades('error');
55         $qformat->setCatfromfile(1);
56         $qformat->setContextfromfile(1);
57         $qformat->setStoponerror(1);
58         $qformat->setCattofile(1);
59         $qformat->setContexttofile(1);
60         $qformat->set_display_progress(false);
62         return $qformat;
63     }
65     /**
66      * Check xml for compliance.
67      * @param string $expectedxml with correct string.
68      * @param string $xml you want to check.
69      */
70     public function assert_same_xml($expectedxml, $xml) {
71         $this->assertEquals($this->normalise_xml($expectedxml),
72                 $this->normalise_xml($xml));
73     }
75     /**
76      * Clean up some XML to remove irrelevant differences, before it is compared.
77      * @param string $xml some XML.
78      * @return string cleaned-up XML.
79      */
80     protected function normalise_xml($xml) {
81         // Normalise line endings.
82         $xml = str_replace("\r\n", "\n", $xml);
83         $xml = preg_replace("~\n$~", "", $xml); // Strip final newline in file.
85         // Replace all numbers in question id comments with 0.
86         $xml = preg_replace('~(?<=<!-- question: )([0-9]+)(?=  -->)~', '0', $xml);
88         // Deal with how different databases output numbers. Only match when only thing in a tag.
89         $xml = preg_replace("~>.0000000<~", '>0<', $xml); // How Oracle outputs 0.0000000.
90         $xml = preg_replace("~(\.(:?[0-9]*[1-9])?)0*<~", '$1<', $xml); // Other cases of trailing 0s
91         $xml = preg_replace("~([0-9]).<~", '$1<', $xml); // Stray . in 1. after last step.
93         return $xml;
94     }
96     /**
97      * Check imported category.
98      * @param string $name imported category name.
99      * @param string $info imported category info field (description of category).
100      * @param int $infoformat imported category info field format.
101      */
102     public function assert_category_imported($name, $info, $infoformat, $idnumber = null) {
103         global $DB;
104         $category = $DB->get_record('question_categories', ['name' => $name], '*', MUST_EXIST);
105         $this->assertEquals($info, $category->info);
106         $this->assertEquals($infoformat, $category->infoformat);
107         $this->assertSame($idnumber, $category->idnumber);
108     }
110     /**
111      * Check a question category has a given parent.
112      * @param string $catname Name of the question category
113      * @param string $parentname Name of the parent category
114      * @throws dml_exception
115      */
116     public function assert_category_has_parent($catname, $parentname) {
117         global $DB;
118         $sql = 'SELECT qc1.*
119                   FROM {question_categories} qc1
120                   JOIN {question_categories} qc2 ON qc1.parent = qc2.id
121                  WHERE qc1.name = ?
122                    AND qc2.name = ?';
123         $categories = $DB->get_records_sql($sql, [$catname, $parentname]);
124         $this->assertTrue(count($categories) == 1);
125     }
127     /**
128      * Check a question exists in a category.
129      * @param string $qname The name of the question
130      * @param string $catname The name of the category
131      * @throws dml_exception
132      */
133     public function assert_question_in_category($qname, $catname) {
134         global $DB;
135         $question = $DB->get_record('question', ['name' => $qname], '*', MUST_EXIST);
136         $category = $DB->get_record('question_categories', ['name' => $catname], '*', MUST_EXIST);
137         $this->assertEquals($category->id, $question->category);
138     }
140     /**
141      * Simple check for importing a category with a description.
142      */
143     public function test_import_category() {
144         $this->resetAfterTest(true);
145         $course = $this->getDataGenerator()->create_course();
146         $this->setAdminUser();
147         $qformat = $this->create_qformat('category_with_description.xml', $course);
148         $imported = $qformat->importprocess();
149         $this->assertTrue($imported);
150         $this->assert_category_imported('Alpha',
151                 'This is Alpha category for test', FORMAT_MOODLE, 'alpha-idnumber');
152         $this->assert_category_has_parent('Alpha', 'top');
153     }
155     /**
156      * Check importing nested categories.
157      */
158     public function test_import_nested_categories() {
159         $this->resetAfterTest(true);
160         $course = $this->getDataGenerator()->create_course();
161         $this->setAdminUser();
162         $qformat = $this->create_qformat('nested_categories.xml', $course);
163         $imported = $qformat->importprocess();
164         $this->assertTrue($imported);
165         $this->assert_category_imported('Delta', 'This is Delta category for test', FORMAT_PLAIN);
166         $this->assert_category_imported('Epsilon', 'This is Epsilon category for test', FORMAT_MARKDOWN);
167         $this->assert_category_imported('Zeta', 'This is Zeta category for test', FORMAT_MOODLE);
168         $this->assert_category_has_parent('Delta', 'top');
169         $this->assert_category_has_parent('Epsilon', 'Delta');
170         $this->assert_category_has_parent('Zeta', 'Epsilon');
171     }
173     /**
174      * Check importing nested categories contain the right questions.
175      */
176     public function test_import_nested_categories_with_questions() {
177         $this->resetAfterTest(true);
178         $course = $this->getDataGenerator()->create_course();
179         $this->setAdminUser();
180         $qformat = $this->create_qformat('nested_categories_with_questions.xml', $course);
181         $imported = $qformat->importprocess();
182         $this->assertTrue($imported);
183         $this->assert_category_imported('Iota', 'This is Iota category for test', FORMAT_PLAIN);
184         $this->assert_category_imported('Kappa', 'This is Kappa category for test', FORMAT_MARKDOWN);
185         $this->assert_category_imported('Lambda', 'This is Lambda category for test', FORMAT_MOODLE);
186         $this->assert_category_imported('Mu', 'This is Mu category for test', FORMAT_MOODLE);
187         $this->assert_question_in_category('Iota Question', 'Iota');
188         $this->assert_question_in_category('Kappa Question', 'Kappa');
189         $this->assert_question_in_category('Lambda Question', 'Lambda');
190         $this->assert_question_in_category('Mu Question', 'Mu');
191         $this->assert_category_has_parent('Iota', 'top');
192         $this->assert_category_has_parent('Kappa', 'Iota');
193         $this->assert_category_has_parent('Lambda', 'Kappa');
194         $this->assert_category_has_parent('Mu', 'Iota');
195     }
197     /**
198      * Check import of an old file (without format), for backward compatability.
199      */
200     public function test_import_old_format() {
201         $this->resetAfterTest(true);
202         $course = $this->getDataGenerator()->create_course();
203         $this->setAdminUser();
204         $qformat = $this->create_qformat('old_format_file.xml', $course);
205         $imported = $qformat->importprocess();
206         $this->assertTrue($imported);
207         $this->assert_category_imported('Pi', '', FORMAT_MOODLE);
208         $this->assert_category_imported('Rho', '', FORMAT_MOODLE);
209         $this->assert_question_in_category('Pi Question', 'Pi');
210         $this->assert_question_in_category('Rho Question', 'Rho');
211         $this->assert_category_has_parent('Pi', 'top');
212         $this->assert_category_has_parent('Rho', 'Pi');
213     }
215     /**
216      * Check the import of an xml file where the child category exists before the parent category.
217      */
218     public function test_import_categories_in_reverse_order() {
219         $this->resetAfterTest(true);
220         $course = $this->getDataGenerator()->create_course();
221         $this->setAdminUser();
222         $qformat = $this->create_qformat('categories_reverse_order.xml', $course);
223         $imported = $qformat->importprocess();
224         $this->assertTrue($imported);
225         $this->assert_category_imported('Sigma', 'This is Sigma category for test', FORMAT_HTML);
226         $this->assert_category_imported('Tau', 'This is Tau category for test', FORMAT_HTML);
227         $this->assert_question_in_category('Sigma Question', 'Sigma');
228         $this->assert_question_in_category('Tau Question', 'Tau');
229         $this->assert_category_has_parent('Sigma', 'top');
230         $this->assert_category_has_parent('Tau', 'Sigma');
231     }
233     /**
234      * Simple check for exporting a category.
235      */
236     public function test_export_category() {
237         $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
238         $this->resetAfterTest(true);
239         $course = $this->getDataGenerator()->create_course();
240         $this->setAdminUser();
241         // Note while this loads $qformat with all the 'right' data from the xml file,
242         // the call to setCategory, followed by exportprocess will actually only export data
243         // from the database (created by the generator).
244         $qformat = $this->create_qformat('export_category.xml', $course);
246         $category = $generator->create_question_category([
247                 'name' => 'Alpha',
248                 'contextid' => '2',
249                 'info' => 'This is Alpha category for test',
250                 'infoformat' => '0',
251                 'idnumber' => 'alpha-idnumber',
252                 'stamp' => make_unique_id_code(),
253                 'parent' => '0',
254                 'sortorder' => '999']);
255         $question = $generator->create_question('truefalse', null, [
256                 'category' => $category->id,
257                 'name' => 'Alpha Question',
258                 'questiontext' => ['format' => '1', 'text' => '<p>Testing Alpha Question</p>'],
259                 'generalfeedback' => ['format' => '1', 'text' => ''],
260                 'correctanswer' => '1',
261                 'feedbacktrue' => ['format' => '1', 'text' => ''],
262                 'feedbackfalse' => ['format' => '1', 'text' => ''],
263                 'penalty' => '1']);
264         $qformat->setCategory($category);
266         $expectedxml = file_get_contents(__DIR__ . '/fixtures/export_category.xml');
267         $this->assert_same_xml($expectedxml, $qformat->exportprocess());
268     }
270     /**
271      * Check exporting nested categories.
272      */
273     public function test_export_nested_categories() {
274         $this->resetAfterTest(true);
275         $course = $this->getDataGenerator()->create_course();
276         $this->setAdminUser();
277         $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
278         $qformat = $this->create_qformat('nested_categories.zml', $course);
280         $categorydelta = $generator->create_question_category([
281                 'name' => 'Delta',
282                 'contextid' => '2',
283                 'info' => 'This is Delta category for test',
284                 'infoformat' => '2',
285                 'stamp' => make_unique_id_code(),
286                 'parent' => '0',
287                 'sortorder' => '999']);
288         $categoryepsilon = $generator->create_question_category([
289                 'name' => 'Epsilon',
290                 'contextid' => '2',
291                 'info' => 'This is Epsilon category for test',
292                 'infoformat' => '4',
293                 'stamp' => make_unique_id_code(),
294                 'parent' => $categorydelta->id,
295                 'sortorder' => '999']);
296         $categoryzeta = $generator->create_question_category([
297                 'name' => 'Zeta',
298                 'contextid' => '2',
299                 'info' => 'This is Zeta category for test',
300                 'infoformat' => '0',
301                 'stamp' => make_unique_id_code(),
302                 'parent' => $categoryepsilon->id,
303                 'sortorder' => '999']);
304         $question  = $generator->create_question('truefalse', null, [
305                 'category' => $categoryzeta->id,
306                 'name' => 'Zeta Question',
307                 'questiontext' => [
308                                 'format' => '1',
309                                 'text' => '<p>Testing Zeta Question</p>'],
310                 'generalfeedback' => ['format' => '1', 'text' => ''],
311                 'correctanswer' => '1',
312                 'feedbacktrue' => ['format' => '1', 'text' => ''],
313                 'feedbackfalse' => ['format' => '1', 'text' => ''],
314                 'penalty' => '1']);
315         $qformat->setCategory($categorydelta);
316         $qformat->setCategory($categoryepsilon);
317         $qformat->setCategory($categoryzeta);
319         $expectedxml = file_get_contents(__DIR__ . '/fixtures/nested_categories.xml');
320         $this->assert_same_xml($expectedxml, $qformat->exportprocess());
321     }
323     /**
324      * Check exporting nested categories contain the right questions.
325      */
326     public function test_export_nested_categories_with_questions() {
327         $this->resetAfterTest(true);
328         $course = $this->getDataGenerator()->create_course();
329         $this->setAdminUser();
330         $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
331         $qformat = $this->create_qformat('nested_categories_with_questions.xml', $course);
333         $categoryiota = $generator->create_question_category([
334                 'name' => 'Iota',
335                 'contextid' => '2',
336                 'info' => 'This is Iota category for test',
337                 'infoformat' => '2',
338                 'stamp' => make_unique_id_code(),
339                 'parent' => '0',
340                 'sortorder' => '999']);
341         $iotaquestion  = $generator->create_question('truefalse', null, [
342                 'category' => $categoryiota->id,
343                 'name' => 'Iota Question',
344                 'questiontext' => [
345                         'format' => '1',
346                         'text' => '<p>Testing Iota Question</p>'],
347                 'generalfeedback' => ['format' => '1', 'text' => ''],
348                 'correctanswer' => '1',
349                 'feedbacktrue' => ['format' => '1', 'text' => ''],
350                 'feedbackfalse' => ['format' => '1', 'text' => ''],
351                 'penalty' => '1']);
352         $categorykappa = $generator->create_question_category([
353                 'name' => 'Kappa',
354                 'contextid' => '2',
355                 'info' => 'This is Kappa category for test',
356                 'infoformat' => '4',
357                 'stamp' => make_unique_id_code(),
358                 'parent' => $categoryiota->id,
359                 'sortorder' => '999']);
360         $kappaquestion  = $generator->create_question('essay', null, [
361                 'category' => $categorykappa->id,
362                 'name' => 'Kappa Essay Question',
363                 'questiontext' => ['text' => 'Testing Kappa Essay Question'],
364                 'generalfeedback' => '',
365                 'responseformat' => 'editor',
366                 'responserequired' => 1,
367                 'responsefieldlines' => 10,
368                 'attachments' => 0,
369                 'attachmentsrequired' => 0,
370                 'graderinfo' => ['format' => '1', 'text' => ''],
371                 'responsetemplate' => ['format' => '1', 'text' => ''],
372                 'idnumber' => '']);
373         $kappaquestion1  = $generator->create_question('truefalse', null, [
374                 'category' => $categorykappa->id,
375                 'name' => 'Kappa Question',
376                 'questiontext' => [
377                         'format' => '1',
378                         'text' => '<p>Testing Kappa Question</p>'],
379                 'generalfeedback' => ['format' => '1', 'text' => ''],
380                 'correctanswer' => '1',
381                 'feedbacktrue' => ['format' => '1', 'text' => ''],
382                 'feedbackfalse' => ['format' => '1', 'text' => ''],
383                 'penalty' => '1',
384                 'idnumber' => '']);
385         $categorylambda = $generator->create_question_category([
386                 'name' => 'Lambda',
387                 'contextid' => '2',
388                 'info' => 'This is Lambda category for test',
389                 'infoformat' => '0',
390                 'stamp' => make_unique_id_code(),
391                 'parent' => $categorykappa->id,
392                 'sortorder' => '999']);
393         $lambdaquestion  = $generator->create_question('truefalse', null, [
394                 'category' => $categorylambda->id,
395                 'name' => 'Lambda Question',
396                 'questiontext' => [
397                         'format' => '1',
398                         'text' => '<p>Testing Lambda Question</p>'],
399                 'generalfeedback' => ['format' => '1', 'text' => ''],
400                 'correctanswer' => '1',
401                 'feedbacktrue' => ['format' => '1', 'text' => ''],
402                 'feedbackfalse' => ['format' => '1', 'text' => ''],
403                 'penalty' => '1']);
404         $categorymu = $generator->create_question_category([
405                 'name' => 'Mu',
406                 'contextid' => '2',
407                 'info' => 'This is Mu category for test',
408                 'infoformat' => '0',
409                 'stamp' => make_unique_id_code(),
410                 'parent' => $categoryiota->id,
411                 'sortorder' => '999']);
412         $muquestion  = $generator->create_question('truefalse', null, [
413                 'category' => $categorymu->id,
414                 'name' => 'Mu Question',
415                 'questiontext' => [
416                         'format' => '1',
417                         'text' => '<p>Testing Mu Question</p>'],
418                 'generalfeedback' => ['format' => '1', 'text' => ''],
419                 'correctanswer' => '1',
420                 'feedbacktrue' => ['format' => '1', 'text' => ''],
421                 'feedbackfalse' => ['format' => '1', 'text' => ''],
422                 'penalty' => '1']);
423         $qformat->setCategory($categoryiota);
425         $expectedxml = file_get_contents(__DIR__ . '/fixtures/nested_categories_with_questions.xml');
426         $this->assert_same_xml($expectedxml, $qformat->exportprocess());
427     }
429     /**
430      * Simple check for exporting a category.
431      */
432     public function test_export_category_with_special_chars() {
433         $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
434         $this->resetAfterTest(true);
435         $course = $this->getDataGenerator()->create_course();
436         $this->setAdminUser();
437         // Note while this loads $qformat with all the 'right' data from the xml file,
438         // the call to setCategory, followed by exportprocess will actually only export data
439         // from the database (created by the generator).
440         $qformat = $this->create_qformat('export_category.xml', $course);
442         $category = $generator->create_question_category([
443                 'name' => 'Alpha',
444                 'contextid' => '2',
445                 'info' => 'This is Alpha category for test',
446                 'infoformat' => '0',
447                 'idnumber' => 'The inequalities < & >',
448                 'stamp' => make_unique_id_code(),
449                 'parent' => '0',
450                 'sortorder' => '999']);
451         $generator->create_question('truefalse', null, [
452                 'category' => $category->id,
453                 'name' => 'Alpha Question',
454                 'questiontext' => ['format' => '1', 'text' => '<p>Testing Alpha Question</p>'],
455                 'generalfeedback' => ['format' => '1', 'text' => ''],
456                 'idnumber' => 'T & F',
457                 'correctanswer' => '1',
458                 'feedbacktrue' => ['format' => '1', 'text' => ''],
459                 'feedbackfalse' => ['format' => '1', 'text' => ''],
460                 'penalty' => '1']);
461         $qformat->setCategory($category);
463         $expectedxml = file_get_contents(__DIR__ . '/fixtures/html_chars_in_idnumbers.xml');
464         $this->assert_same_xml($expectedxml, $qformat->exportprocess());
465     }
467     /**
468      * Test that bad multianswer questions are not imported.
469      */
470     public function test_import_broken_multianswer_questions() {
471         $lines = file(__DIR__ . '/fixtures/broken_cloze_questions.xml');
472         $importer = $qformat = new qformat_xml();
474         // The importer echoes some errors, so we need to capture and check that.
475         ob_start();
476         $questions = $importer->readquestions($lines);
477         $output = ob_get_contents();
478         ob_end_clean();
480         // Check that there were some expected errors.
481         $this->assertContains('Error importing question', $output);
482         $this->assertContains('Invalid embedded answers (Cloze) question', $output);
483         $this->assertContains('This type of question requires at least 2 choices', $output);
484         $this->assertContains('The answer must be a number, for example -1.234 or 3e8, or \'*\'.', $output);
485         $this->assertContains('One of the answers should have a score of 100% so it is possible to get full marks for this question.',
486                 $output);
487         $this->assertContains('The question text must include at least one embedded answer.', $output);
489         // No question  have been imported.
490         $this->assertCount(0, $questions);
491     }