Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

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

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