Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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

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