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 311] [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  /**
  18   * Steps definitions related to mod_quiz.
  19   *
  20   * @package   mod_quiz
  21   * @category  test
  22   * @copyright 2014 Marina Glancy
  23   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
  27  
  28  require_once (__DIR__ . '/../../../../lib/behat/behat_base.php');
  29  require_once (__DIR__ . '/../../../../question/tests/behat/behat_question_base.php');
  30  
  31  use Behat\Gherkin\Node\TableNode as TableNode;
  32  
  33  use Behat\Mink\Exception\ExpectationException as ExpectationException;
  34  
  35  /**
  36   * Steps definitions related to mod_quiz.
  37   *
  38   * @copyright 2014 Marina Glancy
  39   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  40   */
  41  class behat_mod_quiz extends behat_question_base {
  42  
  43      /**
  44       * Convert page names to URLs for steps like 'When I am on the "[page name]" page'.
  45       *
  46       * Recognised page names are:
  47       * | None so far!      |                                                              |
  48       *
  49       * @param string $page name of the page, with the component name removed e.g. 'Admin notification'.
  50       * @return moodle_url the corresponding URL.
  51       * @throws Exception with a meaningful error message if the specified page cannot be found.
  52       */
  53      protected function resolve_page_url(string $page): moodle_url {
  54          switch (strtolower($page)) {
  55              default:
  56                  throw new Exception('Unrecognised quiz page type "' . $page . '."');
  57          }
  58      }
  59  
  60      /**
  61       * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
  62       *
  63       * Recognised page names are:
  64       * | pagetype          | name meaning                                | description                                  |
  65       * | View              | Quiz name                                   | The quiz info page (view.php)                |
  66       * | Edit              | Quiz name                                   | The edit quiz page (edit.php)                |
  67       * | Group overrides   | Quiz name                                   | The manage group overrides page              |
  68       * | User overrides    | Quiz name                                   | The manage user overrides page               |
  69       * | Grades report     | Quiz name                                   | The overview report for a quiz               |
  70       * | Responses report  | Quiz name                                   | The responses report for a quiz              |
  71       * | Statistics report | Quiz name                                   | The statistics report for a quiz             |
  72       * | Attempt review    | Quiz name > username > [Attempt] attempt no | Review page for a given attempt (review.php) |
  73       *
  74       * @param string $type identifies which type of page this is, e.g. 'Attempt review'.
  75       * @param string $identifier identifies the particular page, e.g. 'Test quiz > student > Attempt 1'.
  76       * @return moodle_url the corresponding URL.
  77       * @throws Exception with a meaningful error message if the specified page cannot be found.
  78       */
  79      protected function resolve_page_instance_url(string $type, string $identifier): moodle_url {
  80          global $DB;
  81  
  82          switch (strtolower($type)) {
  83              case 'view':
  84                  return new moodle_url('/mod/quiz/view.php',
  85                          ['id' => $this->get_cm_by_quiz_name($identifier)->id]);
  86  
  87              case 'edit':
  88                  return new moodle_url('/mod/quiz/edit.php',
  89                          ['cmid' => $this->get_cm_by_quiz_name($identifier)->id]);
  90  
  91              case 'group overrides':
  92                  return new moodle_url('/mod/quiz/overrides.php',
  93                      ['cmid' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'group']);
  94  
  95              case 'user overrides':
  96                  return new moodle_url('/mod/quiz/overrides.php',
  97                      ['cmid' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'user']);
  98  
  99              case 'grades report':
 100                  return new moodle_url('/mod/quiz/report.php',
 101                      ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'overview']);
 102  
 103              case 'responses report':
 104                  return new moodle_url('/mod/quiz/report.php',
 105                      ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'responses']);
 106  
 107              case 'statistics report':
 108                  return new moodle_url('/mod/quiz/report.php',
 109                      ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'statistics']);
 110  
 111              case 'manual grading report':
 112                  return new moodle_url('/mod/quiz/report.php',
 113                          ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'grading']);
 114  
 115              case 'attempt review':
 116                  if (substr_count($identifier, ' > ') !== 2) {
 117                      throw new coding_exception('For "attempt review", name must be ' .
 118                              '"{Quiz name} > {username} > Attempt {attemptnumber}", ' .
 119                              'for example "Quiz 1 > student > Attempt 1".');
 120                  }
 121                  list($quizname, $username, $attemptno) = explode(' > ', $identifier);
 122                  $attemptno = (int) trim(str_replace ('Attempt', '', $attemptno));
 123                  $quiz = $this->get_quiz_by_name($quizname);
 124                  $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
 125                  $attempt = $DB->get_record('quiz_attempts',
 126                          ['quiz' => $quiz->id, 'userid' => $user->id, 'attempt' => $attemptno], '*', MUST_EXIST);
 127                  return new moodle_url('/mod/quiz/review.php', ['attempt' => $attempt->id]);
 128  
 129              default:
 130                  throw new Exception('Unrecognised quiz page type "' . $type . '."');
 131          }
 132      }
 133  
 134      /**
 135       * Get a quiz by name.
 136       *
 137       * @param string $name quiz name.
 138       * @return stdClass the corresponding DB row.
 139       */
 140      protected function get_quiz_by_name(string $name): stdClass {
 141          global $DB;
 142          return $DB->get_record('quiz', array('name' => $name), '*', MUST_EXIST);
 143      }
 144  
 145      /**
 146       * Get a quiz cmid from the quiz name.
 147       *
 148       * @param string $name quiz name.
 149       * @return stdClass cm from get_coursemodule_from_instance.
 150       */
 151      protected function get_cm_by_quiz_name(string $name): stdClass {
 152          $quiz = $this->get_quiz_by_name($name);
 153          return get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course);
 154      }
 155  
 156      /**
 157       * Put the specified questions on the specified pages of a given quiz.
 158       *
 159       * The first row should be column names:
 160       * | question | page | maxmark | requireprevious |
 161       * The first two of those are required. The others are optional.
 162       *
 163       * question        needs to uniquely match a question name.
 164       * page            is a page number. Must start at 1, and on each following
 165       *                 row should be the same as the previous, or one more.
 166       * maxmark         What the question is marked out of. Defaults to question.defaultmark.
 167       * requireprevious The question can only be attempted after the previous one was completed.
 168       *
 169       * Then there should be a number of rows of data, one for each question you want to add.
 170       *
 171       * For backwards-compatibility reasons, specifying the column names is optional
 172       * (but strongly encouraged). If not specified, the columns are asseumed to be
 173       * | question | page | maxmark |.
 174       *
 175       * @param string $quizname the name of the quiz to add questions to.
 176       * @param TableNode $data information about the questions to add.
 177       *
 178       * @Given /^quiz "([^"]*)" contains the following questions:$/
 179       */
 180      public function quiz_contains_the_following_questions($quizname, TableNode $data) {
 181          global $DB;
 182  
 183          $quiz = $this->get_quiz_by_name($quizname);
 184  
 185          // Deal with backwards-compatibility, optional first row.
 186          $firstrow = $data->getRow(0);
 187          if (!in_array('question', $firstrow) && !in_array('page', $firstrow)) {
 188              if (count($firstrow) == 2) {
 189                  $headings = array('question', 'page');
 190              } else if (count($firstrow) == 3) {
 191                  $headings = array('question', 'page', 'maxmark');
 192              } else {
 193                  throw new ExpectationException('When adding questions to a quiz, you should give 2 or three 3 things: ' .
 194                          ' the question name, the page number, and optionally the maximum mark. ' .
 195                          count($firstrow) . ' values passed.', $this->getSession());
 196              }
 197              $rows = $data->getRows();
 198              array_unshift($rows, $headings);
 199              $data = new TableNode($rows);
 200          }
 201  
 202          // Add the questions.
 203          $lastpage = 0;
 204          foreach ($data->getHash() as $questiondata) {
 205              if (!array_key_exists('question', $questiondata)) {
 206                  throw new ExpectationException('When adding questions to a quiz, ' .
 207                          'the question name column is required.', $this->getSession());
 208              }
 209              if (!array_key_exists('page', $questiondata)) {
 210                  throw new ExpectationException('When adding questions to a quiz, ' .
 211                          'the page number column is required.', $this->getSession());
 212              }
 213  
 214              // Question id, category and type.
 215              $question = $DB->get_record('question', array('name' => $questiondata['question']), 'id, category, qtype', MUST_EXIST);
 216  
 217              // Page number.
 218              $page = clean_param($questiondata['page'], PARAM_INT);
 219              if ($page <= 0 || (string) $page !== $questiondata['page']) {
 220                  throw new ExpectationException('The page number for question "' .
 221                           $questiondata['question'] . '" must be a positive integer.',
 222                          $this->getSession());
 223              }
 224              if ($page < $lastpage || $page > $lastpage + 1) {
 225                  throw new ExpectationException('When adding questions to a quiz, ' .
 226                          'the page number for each question must either be the same, ' .
 227                          'or one more, then the page number for the previous question.',
 228                          $this->getSession());
 229              }
 230              $lastpage = $page;
 231  
 232              // Max mark.
 233              if (!array_key_exists('maxmark', $questiondata) || $questiondata['maxmark'] === '') {
 234                  $maxmark = null;
 235              } else {
 236                  $maxmark = clean_param($questiondata['maxmark'], PARAM_FLOAT);
 237                  if (!is_numeric($questiondata['maxmark']) || $maxmark < 0) {
 238                      throw new ExpectationException('The max mark for question "' .
 239                              $questiondata['question'] . '" must be a positive number.',
 240                              $this->getSession());
 241                  }
 242              }
 243  
 244              if ($question->qtype == 'random') {
 245                  if (!array_key_exists('includingsubcategories', $questiondata) || $questiondata['includingsubcategories'] === '') {
 246                      $includingsubcategories = false;
 247                  } else {
 248                      $includingsubcategories = clean_param($questiondata['includingsubcategories'], PARAM_BOOL);
 249                  }
 250                  quiz_add_random_questions($quiz, $page, $question->category, 1, $includingsubcategories);
 251              } else {
 252                  // Add the question.
 253                  quiz_add_quiz_question($question->id, $quiz, $page, $maxmark);
 254              }
 255  
 256              // Require previous.
 257              if (array_key_exists('requireprevious', $questiondata)) {
 258                  if ($questiondata['requireprevious'] === '1') {
 259                      $slot = $DB->get_field('quiz_slots', 'MAX(slot)', array('quizid' => $quiz->id));
 260                      $DB->set_field('quiz_slots', 'requireprevious', 1,
 261                              array('quizid' => $quiz->id, 'slot' => $slot));
 262                  } else if ($questiondata['requireprevious'] !== '' && $questiondata['requireprevious'] !== '0') {
 263                      throw new ExpectationException('Require previous for question "' .
 264                              $questiondata['question'] . '" should be 0, 1 or blank.',
 265                              $this->getSession());
 266                  }
 267              }
 268          }
 269  
 270          quiz_update_sumgrades($quiz);
 271      }
 272  
 273      /**
 274       * Put the specified section headings to start at specified pages of a given quiz.
 275       *
 276       * The first row should be column names:
 277       * | heading | firstslot | shufflequestions |
 278       *
 279       * heading   is the section heading text
 280       * firstslot is the slot number where the section starts
 281       * shuffle   whether this section is shuffled (0 or 1)
 282       *
 283       * Then there should be a number of rows of data, one for each section you want to add.
 284       *
 285       * @param string $quizname the name of the quiz to add sections to.
 286       * @param TableNode $data information about the sections to add.
 287       *
 288       * @Given /^quiz "([^"]*)" contains the following sections:$/
 289       */
 290      public function quiz_contains_the_following_sections($quizname, TableNode $data) {
 291          global $DB;
 292  
 293          $quiz = $DB->get_record('quiz', array('name' => $quizname), '*', MUST_EXIST);
 294  
 295          // Add the sections.
 296          $previousfirstslot = 0;
 297          foreach ($data->getHash() as $rownumber => $sectiondata) {
 298              if (!array_key_exists('heading', $sectiondata)) {
 299                  throw new ExpectationException('When adding sections to a quiz, ' .
 300                          'the heading name column is required.', $this->getSession());
 301              }
 302              if (!array_key_exists('firstslot', $sectiondata)) {
 303                  throw new ExpectationException('When adding sections to a quiz, ' .
 304                          'the firstslot name column is required.', $this->getSession());
 305              }
 306              if (!array_key_exists('shuffle', $sectiondata)) {
 307                  throw new ExpectationException('When adding sections to a quiz, ' .
 308                          'the shuffle name column is required.', $this->getSession());
 309              }
 310  
 311              if ($rownumber == 0) {
 312                  $section = $DB->get_record('quiz_sections', array('quizid' => $quiz->id), '*', MUST_EXIST);
 313              } else {
 314                  $section = new stdClass();
 315                  $section->quizid = $quiz->id;
 316              }
 317  
 318              // Heading.
 319              $section->heading = $sectiondata['heading'];
 320  
 321              // First slot.
 322              $section->firstslot = clean_param($sectiondata['firstslot'], PARAM_INT);
 323              if ($section->firstslot <= $previousfirstslot ||
 324                      (string) $section->firstslot !== $sectiondata['firstslot']) {
 325                  throw new ExpectationException('The firstslot number for section "' .
 326                          $sectiondata['heading'] . '" must an integer greater than the previous section firstslot.',
 327                          $this->getSession());
 328              }
 329              if ($rownumber == 0 && $section->firstslot != 1) {
 330                  throw new ExpectationException('The first section must have firstslot set to 1.',
 331                          $this->getSession());
 332              }
 333  
 334              // Shuffle.
 335              $section->shufflequestions = clean_param($sectiondata['shuffle'], PARAM_INT);
 336              if ((string) $section->shufflequestions !== $sectiondata['shuffle']) {
 337                  throw new ExpectationException('The shuffle value for section "' .
 338                          $sectiondata['heading'] . '" must be 0 or 1.',
 339                          $this->getSession());
 340              }
 341  
 342              if ($rownumber == 0) {
 343                  $DB->update_record('quiz_sections', $section);
 344              } else {
 345                  $DB->insert_record('quiz_sections', $section);
 346              }
 347          }
 348  
 349          if ($section->firstslot > $DB->count_records('quiz_slots', array('quizid' => $quiz->id))) {
 350              throw new ExpectationException('The section firstslot must be less than the total number of slots in the quiz.',
 351                      $this->getSession());
 352          }
 353      }
 354  
 355      /**
 356       * Adds a question to the existing quiz with filling the form.
 357       *
 358       * The form for creating a question should be on one page.
 359       *
 360       * @When /^I add a "(?P<question_type_string>(?:[^"]|\\")*)" question to the "(?P<quiz_name_string>(?:[^"]|\\")*)" quiz with:$/
 361       * @param string $questiontype
 362       * @param string $quizname
 363       * @param TableNode $questiondata with data for filling the add question form
 364       */
 365      public function i_add_question_to_the_quiz_with($questiontype, $quizname, TableNode $questiondata) {
 366          $quizname = $this->escape($quizname);
 367          $addaquestion = $this->escape(get_string('addaquestion', 'quiz'));
 368  
 369          $this->execute('behat_navigation::i_am_on_page_instance', [
 370              $quizname,
 371              'mod_quiz > Edit',
 372          ]);
 373  
 374          if ($this->running_javascript()) {
 375              $this->execute("behat_action_menu::i_open_the_action_menu_in", array('.slots', "css_element"));
 376              $this->execute("behat_action_menu::i_choose_in_the_open_action_menu", array($addaquestion));
 377          } else {
 378              $this->execute('behat_general::click_link', $addaquestion);
 379          }
 380  
 381          $this->finish_adding_question($questiontype, $questiondata);
 382      }
 383  
 384      /**
 385       * Set the max mark for a question on the Edit quiz page.
 386       *
 387       * @When /^I set the max mark for question "(?P<question_name_string>(?:[^"]|\\")*)" to "(?P<new_mark_string>(?:[^"]|\\")*)"$/
 388       * @param string $questionname the name of the question to set the max mark for.
 389       * @param string $newmark the mark to set
 390       */
 391      public function i_set_the_max_mark_for_quiz_question($questionname, $newmark) {
 392          $this->execute('behat_general::click_link', $this->escape(get_string('editmaxmark', 'quiz')));
 393  
 394          $this->execute('behat_general::wait_until_exists', array("li input[name=maxmark]", "css_element"));
 395  
 396          $this->execute('behat_general::assert_page_contains_text', $this->escape(get_string('edittitleinstructions')));
 397  
 398          $this->execute('behat_general::i_type', [$newmark]);
 399          $this->execute('behat_general::i_press_named_key', ['', 'enter']);
 400      }
 401  
 402      /**
 403       * Open the add menu on a given page, or at the end of the Edit quiz page.
 404       * @Given /^I open the "(?P<page_n_or_last_string>(?:[^"]|\\")*)" add to quiz menu$/
 405       * @param string $pageorlast either "Page n" or "last".
 406       */
 407      public function i_open_the_add_to_quiz_menu_for($pageorlast) {
 408  
 409          if (!$this->running_javascript()) {
 410              throw new DriverException('Activities actions menu not available when Javascript is disabled');
 411          }
 412  
 413          if ($pageorlast == 'last') {
 414              $xpath = "//div[@class = 'last-add-menu']//a[contains(@data-toggle, 'dropdown') and contains(., 'Add')]";
 415          } else if (preg_match('~Page (\d+)~', $pageorlast, $matches)) {
 416              $xpath = "//li[@id = 'page-{$matches[1]}']//a[contains(@data-toggle, 'dropdown') and contains(., 'Add')]";
 417          } else {
 418              throw new ExpectationException("The I open the add to quiz menu step must specify either 'Page N' or 'last'.",
 419                  $this->getSession());
 420          }
 421          $this->find('xpath', $xpath)->click();
 422      }
 423  
 424      /**
 425       * Check whether a particular question is on a particular page of the quiz on the Edit quiz page.
 426       * @Given /^I should see "(?P<question_name>(?:[^"]|\\")*)" on quiz page "(?P<page_number>\d+)"$/
 427       * @param string $questionname the name of the question we are looking for.
 428       * @param number $pagenumber the page it should be found on.
 429       */
 430      public function i_should_see_on_quiz_page($questionname, $pagenumber) {
 431          $xpath = "//li[contains(., '" . $this->escape($questionname) .
 432                  "')][./preceding-sibling::li[contains(@class, 'pagenumber')][1][contains(., 'Page " .
 433                  $pagenumber . "')]]";
 434  
 435          $this->execute('behat_general::should_exist', array($xpath, 'xpath_element'));
 436      }
 437  
 438      /**
 439       * Check whether a particular question is not on a particular page of the quiz on the Edit quiz page.
 440       * @Given /^I should not see "(?P<question_name>(?:[^"]|\\")*)" on quiz page "(?P<page_number>\d+)"$/
 441       * @param string $questionname the name of the question we are looking for.
 442       * @param number $pagenumber the page it should be found on.
 443       */
 444      public function i_should_not_see_on_quiz_page($questionname, $pagenumber) {
 445          $xpath = "//li[contains(., '" . $this->escape($questionname) .
 446                  "')][./preceding-sibling::li[contains(@class, 'pagenumber')][1][contains(., 'Page " .
 447                  $pagenumber . "')]]";
 448  
 449          $this->execute('behat_general::should_not_exist', array($xpath, 'xpath_element'));
 450      }
 451  
 452      /**
 453       * Check whether one question comes before another on the Edit quiz page.
 454       * The two questions must be on the same page.
 455       * @Given /^I should see "(?P<first_q_name>(?:[^"]|\\")*)" before "(?P<second_q_name>(?:[^"]|\\")*)" on the edit quiz page$/
 456       * @param string $firstquestionname the name of the question that should come first in order.
 457       * @param string $secondquestionname the name of the question that should come immediately after it in order.
 458       */
 459      public function i_should_see_before_on_the_edit_quiz_page($firstquestionname, $secondquestionname) {
 460          $xpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($firstquestionname) .
 461                  "')]/following-sibling::li[contains(@class, ' slot ')][1]" .
 462                  "[contains(., '" . $this->escape($secondquestionname) . "')]";
 463  
 464          $this->execute('behat_general::should_exist', array($xpath, 'xpath_element'));
 465      }
 466  
 467      /**
 468       * Check the number displayed alongside a question on the Edit quiz page.
 469       * @Given /^"(?P<question_name>(?:[^"]|\\")*)" should have number "(?P<number>(?:[^"]|\\")*)" on the edit quiz page$/
 470       * @param string $questionname the name of the question we are looking for.
 471       * @param number $number the number (or 'i') that should be displayed beside that question.
 472       */
 473      public function should_have_number_on_the_edit_quiz_page($questionname, $number) {
 474          $xpath = "//li[contains(@class, 'slot') and contains(., '" . $this->escape($questionname) .
 475                  "')]//span[contains(@class, 'slotnumber') and normalize-space(text()) = '" . $this->escape($number) . "']";
 476  
 477          $this->execute('behat_general::should_exist', array($xpath, 'xpath_element'));
 478      }
 479  
 480      /**
 481       * Get the xpath for a partcular add/remove page-break icon.
 482       * @param string $addorremoves 'Add' or 'Remove'.
 483       * @param string $questionname the name of the question before the icon.
 484       * @return string the requried xpath.
 485       */
 486      protected function get_xpath_page_break_icon_after_question($addorremoves, $questionname) {
 487          return "//li[contains(@class, 'slot') and contains(., '" . $this->escape($questionname) .
 488                  "')]//a[contains(@class, 'page_split_join') and @title = '" . $addorremoves . " page break']";
 489      }
 490  
 491      /**
 492       * Click the add or remove page-break icon after a particular question.
 493       * @When /^I click on the "(Add|Remove)" page break icon after question "(?P<question_name>(?:[^"]|\\")*)"$/
 494       * @param string $addorremoves 'Add' or 'Remove'.
 495       * @param string $questionname the name of the question before the icon to click.
 496       */
 497      public function i_click_on_the_page_break_icon_after_question($addorremoves, $questionname) {
 498          $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname);
 499  
 500          $this->execute("behat_general::i_click_on", array($xpath, "xpath_element"));
 501      }
 502  
 503      /**
 504       * Assert the add or remove page-break icon after a particular question exists.
 505       * @When /^the "(Add|Remove)" page break icon after question "(?P<question_name>(?:[^"]|\\")*)" should exist$/
 506       * @param string $addorremoves 'Add' or 'Remove'.
 507       * @param string $questionname the name of the question before the icon to click.
 508       * @return array of steps.
 509       */
 510      public function the_page_break_icon_after_question_should_exist($addorremoves, $questionname) {
 511          $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname);
 512  
 513          $this->execute('behat_general::should_exist', array($xpath, 'xpath_element'));
 514      }
 515  
 516      /**
 517       * Assert the add or remove page-break icon after a particular question does not exist.
 518       * @When /^the "(Add|Remove)" page break icon after question "(?P<question_name>(?:[^"]|\\")*)" should not exist$/
 519       * @param string $addorremoves 'Add' or 'Remove'.
 520       * @param string $questionname the name of the question before the icon to click.
 521       * @return array of steps.
 522       */
 523      public function the_page_break_icon_after_question_should_not_exist($addorremoves, $questionname) {
 524          $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname);
 525  
 526          $this->execute('behat_general::should_not_exist', array($xpath, 'xpath_element'));
 527      }
 528  
 529      /**
 530       * Check the add or remove page-break link after a particular question contains the given parameters in its url.
 531       * @When /^the "(Add|Remove)" page break link after question "(?P<question_name>(?:[^"]|\\")*) should contain:"$/
 532       * @param string $addorremoves 'Add' or 'Remove'.
 533       * @param string $questionname the name of the question before the icon to click.
 534       * @param TableNode $paramdata with data for checking the page break url
 535       * @return array of steps.
 536       */
 537      public function the_page_break_link_after_question_should_contain($addorremoves, $questionname, $paramdata) {
 538          $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname);
 539  
 540          $this->execute("behat_general::i_click_on", array($xpath, "xpath_element"));
 541      }
 542  
 543      /**
 544       * Set Shuffle for shuffling questions within sections
 545       *
 546       * @param string $heading the heading of the section to change shuffle for.
 547       *
 548       * @Given /^I click on shuffle for section "([^"]*)" on the quiz edit page$/
 549       */
 550      public function i_click_on_shuffle_for_section($heading) {
 551          $xpath = $this->get_xpath_for_shuffle_checkbox($heading);
 552          $checkbox = $this->find('xpath', $xpath);
 553          $this->ensure_node_is_visible($checkbox);
 554          $checkbox->click();
 555      }
 556  
 557      /**
 558       * Check the shuffle checkbox for a particular section.
 559       *
 560       * @param string $heading the heading of the section to check shuffle for
 561       * @param int $value whether the shuffle checkbox should be on or off.
 562       *
 563       * @Given /^shuffle for section "([^"]*)" should be "(On|Off)" on the quiz edit page$/
 564       */
 565      public function shuffle_for_section_should_be($heading, $value) {
 566          $xpath = $this->get_xpath_for_shuffle_checkbox($heading);
 567          $checkbox = $this->find('xpath', $xpath);
 568          $this->ensure_node_is_visible($checkbox);
 569          if ($value == 'On' && !$checkbox->isChecked()) {
 570              $msg = "Shuffle for section '$heading' is not checked, but you are expecting it to be checked ($value). " .
 571                      "Check the line with: \nshuffle for section \"$heading\" should be \"$value\" on the quiz edit page" .
 572                      "\nin your behat script";
 573              throw new ExpectationException($msg, $this->getSession());
 574          } else if ($value == 'Off' && $checkbox->isChecked()) {
 575              $msg = "Shuffle for section '$heading' is checked, but you are expecting it not to be ($value). " .
 576                      "Check the line with: \nshuffle for section \"$heading\" should be \"$value\" on the quiz edit page" .
 577                      "\nin your behat script";
 578              throw new ExpectationException($msg, $this->getSession());
 579          }
 580      }
 581  
 582      /**
 583       * Return the xpath for shuffle checkbox in section heading
 584       * @param string $heading
 585       * @return string
 586       */
 587      protected function get_xpath_for_shuffle_checkbox($heading) {
 588           return "//div[contains(@class, 'section-heading') and contains(., '" . $this->escape($heading) .
 589                  "')]//input[@type = 'checkbox']";
 590      }
 591  
 592      /**
 593       * Move a question on the Edit quiz page by first clicking on the Move icon,
 594       * then clicking one of the "After ..." links.
 595       * @When /^I move "(?P<question_name>(?:[^"]|\\")*)" to "(?P<target>(?:[^"]|\\")*)" in the quiz by clicking the move icon$/
 596       * @param string $questionname the name of the question we are looking for.
 597       * @param string $target the target place to move to. One of the links in the pop-up like
 598       *      "After Page 1" or "After Question N".
 599       */
 600      public function i_move_question_after_item_by_clicking_the_move_icon($questionname, $target) {
 601          $iconxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) .
 602                  "')]//span[contains(@class, 'editing_move')]";
 603  
 604          $this->execute("behat_general::i_click_on", array($iconxpath, "xpath_element"));
 605          $this->execute("behat_general::i_click_on", array($this->escape($target), "text"));
 606      }
 607  
 608      /**
 609       * Move a question on the Edit quiz page by dragging a given question on top of another item.
 610       * @When /^I move "(?P<question_name>(?:[^"]|\\")*)" to "(?P<target>(?:[^"]|\\")*)" in the quiz by dragging$/
 611       * @param string $questionname the name of the question we are looking for.
 612       * @param string $target the target place to move to. Ether a question name, or "Page N"
 613       */
 614      public function i_move_question_after_item_by_dragging($questionname, $target) {
 615          $iconxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) .
 616                  "')]//span[contains(@class, 'editing_move')]//img";
 617          $destinationxpath = "//li[contains(@class, ' slot ') or contains(@class, 'pagenumber ')]" .
 618                  "[contains(., '" . $this->escape($target) . "')]";
 619  
 620          $this->execute('behat_general::i_drag_and_i_drop_it_in',
 621              array($iconxpath, 'xpath_element', $destinationxpath, 'xpath_element')
 622          );
 623      }
 624  
 625      /**
 626       * Delete a question on the Edit quiz page by first clicking on the Delete icon,
 627       * then clicking one of the "After ..." links.
 628       * @When /^I delete "(?P<question_name>(?:[^"]|\\")*)" in the quiz by clicking the delete icon$/
 629       * @param string $questionname the name of the question we are looking for.
 630       * @return array of steps.
 631       */
 632      public function i_delete_question_by_clicking_the_delete_icon($questionname) {
 633          $slotxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) .
 634                  "')]";
 635          $deletexpath = "//a[contains(@class, 'editing_delete')]";
 636  
 637          $this->execute("behat_general::i_click_on", array($slotxpath . $deletexpath, "xpath_element"));
 638  
 639          $this->execute('behat_general::i_click_on_in_the',
 640              array('Yes', "button", "Confirm", "dialogue")
 641          );
 642      }
 643  
 644      /**
 645       * Set the section heading for a given section on the Edit quiz page
 646       *
 647       * @When /^I change quiz section heading "(?P<section_name_string>(?:[^"]|\\")*)" to "(?P<new_section_heading_string>(?:[^"]|\\")*)"$/
 648       * @param string $sectionname the heading to change.
 649       * @param string $sectionheading the new heading to set.
 650       */
 651      public function i_set_the_section_heading_for($sectionname, $sectionheading) {
 652          $this->execute('behat_general::click_link', $this->escape("Edit heading '{$sectionname}'"));
 653  
 654          $this->execute('behat_general::assert_page_contains_text', $this->escape(get_string('edittitleinstructions')));
 655  
 656          $this->execute('behat_general::i_press_named_key', ['', 'backspace']);
 657          $this->execute('behat_general::i_type', [$sectionheading]);
 658          $this->execute('behat_general::i_press_named_key', ['', 'enter']);
 659      }
 660  
 661      /**
 662       * Check that a given question comes after a given section heading in the
 663       * quiz navigation block.
 664       *
 665       * @Then /^I should see question "(?P<questionnumber>\d+)" in section "(?P<section_heading_string>(?:[^"]|\\")*)" in the quiz navigation$/
 666       * @param int $questionnumber the number of the question to check.
 667       * @param string $sectionheading which section heading it should appear after.
 668       */
 669      public function i_should_see_question_in_section_in_the_quiz_navigation($questionnumber, $sectionheading) {
 670  
 671          // Using xpath literal to avoid quotes problems.
 672          $questionnumberliteral = behat_context_helper::escape('Question ' . $questionnumber);
 673          $headingliteral = behat_context_helper::escape($sectionheading);
 674  
 675          // Split in two checkings to give more feedback in case of exception.
 676          $exception = new ExpectationException('Question "' . $questionnumber . '" is not in section "' .
 677                  $sectionheading . '" in the quiz navigation.', $this->getSession());
 678          $xpath = "//*[@id = 'mod_quiz_navblock']//*[contains(concat(' ', normalize-space(@class), ' '), ' qnbutton ') and " .
 679                  "contains(., {$questionnumberliteral}) and contains(preceding-sibling::h3[1], {$headingliteral})]";
 680          $this->find('xpath', $xpath);
 681      }
 682  
 683      /**
 684       * Helper used by user_has_attempted_with_responses,
 685       * user_has_started_an_attempt_at_quiz_with_details, etc.
 686       *
 687       * @param TableNode $attemptinfo data table from the Behat step
 688       * @return array with two elements, $forcedrandomquestions, $forcedvariants,
 689       *      that can be passed to $quizgenerator->create_attempt.
 690       */
 691      protected function extract_forced_randomisation_from_attempt_info(TableNode $attemptinfo) {
 692          global $DB;
 693  
 694          $forcedrandomquestions = [];
 695          $forcedvariants = [];
 696          foreach ($attemptinfo->getHash() as $slotinfo) {
 697              if (empty($slotinfo['slot'])) {
 698                  throw new ExpectationException('When simulating a quiz attempt, ' .
 699                          'the slot column is required.', $this->getSession());
 700              }
 701  
 702              if (!empty($slotinfo['actualquestion'])) {
 703                  $forcedrandomquestions[$slotinfo['slot']] = $DB->get_field('question', 'id',
 704                          ['name' => $slotinfo['actualquestion']], MUST_EXIST);
 705              }
 706  
 707              if (!empty($slotinfo['variant'])) {
 708                  $forcedvariants[$slotinfo['slot']] = (int) $slotinfo['variant'];
 709              }
 710          }
 711          return [$forcedrandomquestions, $forcedvariants];
 712      }
 713  
 714      /**
 715       * Helper used by user_has_attempted_with_responses, user_has_checked_answers_in_their_attempt_at_quiz,
 716       * user_has_input_answers_in_their_attempt_at_quiz, etc.
 717       *
 718       * @param TableNode $attemptinfo data table from the Behat step
 719       * @return array of responses that can be passed to $quizgenerator->submit_responses.
 720       */
 721      protected function extract_responses_from_attempt_info(TableNode $attemptinfo) {
 722          $responses = [];
 723          foreach ($attemptinfo->getHash() as $slotinfo) {
 724              if (empty($slotinfo['slot'])) {
 725                  throw new ExpectationException('When simulating a quiz attempt, ' .
 726                          'the slot column is required.', $this->getSession());
 727              }
 728              if (!array_key_exists('response', $slotinfo)) {
 729                  throw new ExpectationException('When simulating a quiz attempt, ' .
 730                          'the response column is required.', $this->getSession());
 731              }
 732              $responses[$slotinfo['slot']] = $slotinfo['response'];
 733          }
 734          return $responses;
 735      }
 736  
 737      /**
 738       * Attempt a quiz.
 739       *
 740       * The first row should be column names:
 741       * | slot | actualquestion | variant | response |
 742       * The first two of those are required. The others are optional.
 743       *
 744       * slot           The slot
 745       * actualquestion This column is optional, and is only needed if the quiz contains
 746       *                random questions. If so, this will let you control which actual
 747       *                question gets picked when this slot is 'randomised' at the
 748       *                start of the attempt. If you don't specify, then one will be picked
 749       *                at random (which might make the response meaningless).
 750       *                Give the question name.
 751       * variant        This column is similar, and also options. It is only needed if
 752       *                the question that ends up in this slot returns something greater
 753       *                than 1 for $question->get_num_variants(). Like with actualquestion,
 754       *                if you specify a value here it is used the fix the 'random' choice
 755       *                made when the quiz is started.
 756       * response       The response that was submitted. How this is interpreted depends on
 757       *                the question type. It gets passed to
 758       *                {@link core_question_generator::get_simulated_post_data_for_question_attempt()}
 759       *                and therefore to the un_summarise_response method of the question to decode.
 760       *
 761       * Then there should be a number of rows of data, one for each question you want to add.
 762       * There is no need to supply answers to all questions. If so, other qusetions will be
 763       * left unanswered.
 764       *
 765       * @param string $username the username of the user that will attempt.
 766       * @param string $quizname the name of the quiz the user will attempt.
 767       * @param TableNode $attemptinfo information about the questions to add, as above.
 768       * @Given /^user "([^"]*)" has attempted "([^"]*)" with responses:$/
 769       */
 770      public function user_has_attempted_with_responses($username, $quizname, TableNode $attemptinfo) {
 771          global $DB;
 772  
 773          /** @var mod_quiz_generator $quizgenerator */
 774          $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz');
 775  
 776          $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
 777          $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
 778  
 779          list($forcedrandomquestions, $forcedvariants) =
 780                  $this->extract_forced_randomisation_from_attempt_info($attemptinfo);
 781          $responses = $this->extract_responses_from_attempt_info($attemptinfo);
 782  
 783          $this->set_user($user);
 784  
 785          $attempt = $quizgenerator->create_attempt($quizid, $user->id,
 786                  $forcedrandomquestions, $forcedvariants);
 787  
 788          $quizgenerator->submit_responses($attempt->id, $responses, false, true);
 789  
 790          $this->set_user();
 791      }
 792  
 793      /**
 794       * Start a quiz attempt without answers.
 795       *
 796       * Then there should be a number of rows of data, one for each question you want to add.
 797       * There is no need to supply answers to all questions. If so, other qusetions will be
 798       * left unanswered.
 799       *
 800       * @param string $username the username of the user that will attempt.
 801       * @param string $quizname the name of the quiz the user will attempt.
 802       * @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*)"$/
 803       */
 804      public function user_has_started_an_attempt_at_quiz($username, $quizname) {
 805          global $DB;
 806  
 807          /** @var mod_quiz_generator $quizgenerator */
 808          $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz');
 809  
 810          $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
 811          $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
 812          $this->set_user($user);
 813          $quizgenerator->create_attempt($quizid, $user->id);
 814          $this->set_user();
 815      }
 816  
 817      /**
 818       * Start a quiz attempt without answers.
 819       *
 820       * The supplied data table for have a row for each slot where you want
 821       * to force either which random question was chose, or which random variant
 822       * was used, as for {@link user_has_attempted_with_responses()} above.
 823       *
 824       * @param string $username the username of the user that will attempt.
 825       * @param string $quizname the name of the quiz the user will attempt.
 826       * @param TableNode $attemptinfo information about the questions to add, as above.
 827       * @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*)" randomised as follows:$/
 828       */
 829      public function user_has_started_an_attempt_at_quiz_with_details($username, $quizname, TableNode $attemptinfo) {
 830          global $DB;
 831  
 832          /** @var mod_quiz_generator $quizgenerator */
 833          $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz');
 834  
 835          $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
 836          $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
 837  
 838          list($forcedrandomquestions, $forcedvariants) =
 839                  $this->extract_forced_randomisation_from_attempt_info($attemptinfo);
 840  
 841          $this->set_user($user);
 842  
 843          $quizgenerator->create_attempt($quizid, $user->id,
 844                  $forcedrandomquestions, $forcedvariants);
 845  
 846          $this->set_user();
 847      }
 848  
 849      /**
 850       * Input answers to particular questions an existing quiz attempt, without
 851       * simulating a click of the 'Check' button, if any.
 852       *
 853       * Then there should be a number of rows of data, with two columns slot and response,
 854       * as for {@link user_has_attempted_with_responses()} above.
 855       * There is no need to supply answers to all questions. If so, other questions will be
 856       * left unanswered.
 857       *
 858       * @param string $username the username of the user that will attempt.
 859       * @param string $quizname the name of the quiz the user will attempt.
 860       * @param TableNode $attemptinfo information about the questions to add, as above.
 861       * @throws \Behat\Mink\Exception\ExpectationException
 862       * @Given /^user "([^"]*)" has input answers in their attempt at quiz "([^"]*)":$/
 863       */
 864      public function user_has_input_answers_in_their_attempt_at_quiz($username, $quizname, TableNode $attemptinfo) {
 865          global $DB;
 866  
 867          /** @var mod_quiz_generator $quizgenerator */
 868          $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz');
 869  
 870          $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
 871          $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
 872  
 873          $responses = $this->extract_responses_from_attempt_info($attemptinfo);
 874  
 875          $this->set_user($user);
 876  
 877          $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true);
 878          $quizgenerator->submit_responses(key($attempts), $responses, false, false);
 879  
 880          $this->set_user();
 881      }
 882  
 883      /**
 884       * Submit answers to questions an existing quiz attempt, with a simulated click on the 'Check' button.
 885       *
 886       * This step should only be used with question behaviours that have have
 887       * a 'Check' button. Those include Interactive with multiple tires, Immediate feedback
 888       * and Immediate feedback with CBM.
 889       *
 890       * Then there should be a number of rows of data, with two columns slot and response,
 891       * as for {@link user_has_attempted_with_responses()} above.
 892       * There is no need to supply answers to all questions. If so, other questions will be
 893       * left unanswered.
 894       *
 895       * @param string $username the username of the user that will attempt.
 896       * @param string $quizname the name of the quiz the user will attempt.
 897       * @param TableNode $attemptinfo information about the questions to add, as above.
 898       * @throws \Behat\Mink\Exception\ExpectationException
 899       * @Given /^user "([^"]*)" has checked answers in their attempt at quiz "([^"]*)":$/
 900       */
 901      public function user_has_checked_answers_in_their_attempt_at_quiz($username, $quizname, TableNode $attemptinfo) {
 902          global $DB;
 903  
 904          /** @var mod_quiz_generator $quizgenerator */
 905          $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz');
 906  
 907          $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
 908          $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
 909  
 910          $responses = $this->extract_responses_from_attempt_info($attemptinfo);
 911  
 912          $this->set_user($user);
 913  
 914          $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true);
 915          $quizgenerator->submit_responses(key($attempts), $responses, true, false);
 916  
 917          $this->set_user();
 918      }
 919  
 920      /**
 921       * Finish an existing quiz attempt.
 922       *
 923       * @param string $username the username of the user that will attempt.
 924       * @param string $quizname the name of the quiz the user will attempt.
 925       * @Given /^user "([^"]*)" has finished an attempt at quiz "([^"]*)"$/
 926       */
 927      public function user_has_finished_an_attempt_at_quiz($username, $quizname) {
 928          global $DB;
 929  
 930          $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
 931          $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
 932  
 933          $this->set_user($user);
 934  
 935          $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true);
 936          $attemptobj = quiz_attempt::create(key($attempts));
 937          $attemptobj->process_finish(time(), true);
 938  
 939          $this->set_user();
 940      }
 941  }