Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403] [Versions 39 and 310]

   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');
  30  
  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);
  61  
  62          return $qformat;
  63      }
  64  
  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      }
  74  
  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.
  84  
  85          // Replace all numbers in question id comments with 0.
  86          $xml = preg_replace('~(?<=<!-- question: )([0-9]+)(?=  -->)~', '0', $xml);
  87  
  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.
  92  
  93          return $xml;
  94      }
  95  
  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      }
 109  
 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      }
 126  
 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      }
 139  
 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      }
 154  
 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      }
 172  
 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      }
 196  
 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      }
 214  
 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      }
 232  
 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);
 245  
 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);
 265  
 266          $expectedxml = file_get_contents(__DIR__ . '/fixtures/export_category.xml');
 267          $this->assert_same_xml($expectedxml, $qformat->exportprocess());
 268      }
 269  
 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);
 279  
 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);
 318  
 319          $expectedxml = file_get_contents(__DIR__ . '/fixtures/nested_categories.xml');
 320          $this->assert_same_xml($expectedxml, $qformat->exportprocess());
 321      }
 322  
 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);
 332  
 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);
 424  
 425          $expectedxml = file_get_contents(__DIR__ . '/fixtures/nested_categories_with_questions.xml');
 426          $this->assert_same_xml($expectedxml, $qformat->exportprocess());
 427      }
 428  
 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);
 441  
 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);
 462  
 463          $expectedxml = file_get_contents(__DIR__ . '/fixtures/html_chars_in_idnumbers.xml');
 464          $this->assert_same_xml($expectedxml, $qformat->exportprocess());
 465      }
 466  
 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();
 473  
 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();
 479  
 480          // Check that there were some expected errors.
 481          $this->assertStringContainsString('Error importing question', $output);
 482          $this->assertStringContainsString('Invalid embedded answers (Cloze) question', $output);
 483          $this->assertStringContainsString('This type of question requires at least 2 choices', $output);
 484          $this->assertStringContainsString('The answer must be a number, for example -1.234 or 3e8, or \'*\'.', $output);
 485          $this->assertStringContainsString('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->assertStringContainsString('The question text must include at least one embedded answer.', $output);
 488  
 489          // No question  have been imported.
 490          $this->assertCount(0, $questions);
 491      }
 492  }