See Release Notes
Long Term Support Release
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body