Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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