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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body