Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402] [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 as TableNode; 32 33 use Behat\Mink\Exception\ExpectationException as 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 quiz_add_random_questions($quiz, $page, $question->category, 1, $includingsubcategories); 280 } else { 281 // Add the question. 282 quiz_add_quiz_question($question->id, $quiz, $page, $maxmark); 283 } 284 285 // Display number (allowing editable customised question number). 286 if (array_key_exists('displaynumber', $questiondata)) { 287 $slot = $DB->get_field('quiz_slots', 'MAX(slot)', ['quizid' => $quiz->id]); 288 $DB->set_field('quiz_slots', 'displaynumber', $questiondata['displaynumber'], 289 ['quizid' => $quiz->id, 'slot' => $slot]); 290 if (!is_number($questiondata['displaynumber']) && !is_string($questiondata['displaynumber'])) { 291 throw new ExpectationException('Displayed question number for "' . $questiondata['question'] . 292 '" should either be \'i\', automatically numbered (eg. 1, 2, 3), 293 or customised (eg. A.1, A.2, 1.1, 1.2)', $this->getSession()); 294 } 295 } 296 297 // Require previous. 298 if (array_key_exists('requireprevious', $questiondata)) { 299 if ($questiondata['requireprevious'] === '1') { 300 $slot = $DB->get_field('quiz_slots', 'MAX(slot)', ['quizid' => $quiz->id]); 301 $DB->set_field('quiz_slots', 'requireprevious', 1, 302 ['quizid' => $quiz->id, 'slot' => $slot]); 303 } else if ($questiondata['requireprevious'] !== '' && $questiondata['requireprevious'] !== '0') { 304 throw new ExpectationException('Require previous for question "' . 305 $questiondata['question'] . '" should be 0, 1 or blank.', 306 $this->getSession()); 307 } 308 } 309 } 310 311 $quizobj = quiz_settings::create($quiz->id); 312 $quizobj->get_grade_calculator()->recompute_quiz_sumgrades(); 313 } 314 315 /** 316 * Put the specified section headings to start at specified pages of a given quiz. 317 * 318 * The first row should be column names: 319 * | heading | firstslot | shufflequestions | 320 * 321 * heading is the section heading text 322 * firstslot is the slot number where the section starts 323 * shuffle whether this section is shuffled (0 or 1) 324 * 325 * Then there should be a number of rows of data, one for each section you want to add. 326 * 327 * @param string $quizname the name of the quiz to add sections to. 328 * @param TableNode $data information about the sections to add. 329 * 330 * @Given /^quiz "([^"]*)" contains the following sections:$/ 331 */ 332 public function quiz_contains_the_following_sections($quizname, TableNode $data) { 333 global $DB; 334 335 $quiz = $DB->get_record('quiz', ['name' => $quizname], '*', MUST_EXIST); 336 337 // Add the sections. 338 $previousfirstslot = 0; 339 foreach ($data->getHash() as $rownumber => $sectiondata) { 340 if (!array_key_exists('heading', $sectiondata)) { 341 throw new ExpectationException('When adding sections to a quiz, ' . 342 'the heading name column is required.', $this->getSession()); 343 } 344 if (!array_key_exists('firstslot', $sectiondata)) { 345 throw new ExpectationException('When adding sections to a quiz, ' . 346 'the firstslot name column is required.', $this->getSession()); 347 } 348 if (!array_key_exists('shuffle', $sectiondata)) { 349 throw new ExpectationException('When adding sections to a quiz, ' . 350 'the shuffle name column is required.', $this->getSession()); 351 } 352 353 if ($rownumber == 0) { 354 $section = $DB->get_record('quiz_sections', ['quizid' => $quiz->id], '*', MUST_EXIST); 355 } else { 356 $section = new stdClass(); 357 $section->quizid = $quiz->id; 358 } 359 360 // Heading. 361 $section->heading = $sectiondata['heading']; 362 363 // First slot. 364 $section->firstslot = clean_param($sectiondata['firstslot'], PARAM_INT); 365 if ($section->firstslot <= $previousfirstslot || 366 (string) $section->firstslot !== $sectiondata['firstslot']) { 367 throw new ExpectationException('The firstslot number for section "' . 368 $sectiondata['heading'] . '" must an integer greater than the previous section firstslot.', 369 $this->getSession()); 370 } 371 if ($rownumber == 0 && $section->firstslot != 1) { 372 throw new ExpectationException('The first section must have firstslot set to 1.', 373 $this->getSession()); 374 } 375 376 // Shuffle. 377 $section->shufflequestions = clean_param($sectiondata['shuffle'], PARAM_INT); 378 if ((string) $section->shufflequestions !== $sectiondata['shuffle']) { 379 throw new ExpectationException('The shuffle value for section "' . 380 $sectiondata['heading'] . '" must be 0 or 1.', 381 $this->getSession()); 382 } 383 384 if ($rownumber == 0) { 385 $DB->update_record('quiz_sections', $section); 386 } else { 387 $DB->insert_record('quiz_sections', $section); 388 } 389 } 390 391 if ($section->firstslot > $DB->count_records('quiz_slots', ['quizid' => $quiz->id])) { 392 throw new ExpectationException('The section firstslot must be less than the total number of slots in the quiz.', 393 $this->getSession()); 394 } 395 } 396 397 /** 398 * Adds a question to the existing quiz with filling the form. 399 * 400 * The form for creating a question should be on one page. 401 * 402 * @When /^I add a "(?P<question_type_string>(?:[^"]|\\")*)" question to the "(?P<quiz_name_string>(?:[^"]|\\")*)" quiz with:$/ 403 * @param string $questiontype 404 * @param string $quizname 405 * @param TableNode $questiondata with data for filling the add question form 406 */ 407 public function i_add_question_to_the_quiz_with($questiontype, $quizname, TableNode $questiondata) { 408 $quizname = $this->escape($quizname); 409 $addaquestion = $this->escape(get_string('addaquestion', 'quiz')); 410 411 $this->execute('behat_navigation::i_am_on_page_instance', [ 412 $quizname, 413 'mod_quiz > Edit', 414 ]); 415 416 if ($this->running_javascript()) { 417 $this->execute("behat_action_menu::i_open_the_action_menu_in", ['.slots', "css_element"]); 418 $this->execute("behat_action_menu::i_choose_in_the_open_action_menu", [$addaquestion]); 419 } else { 420 $this->execute('behat_general::click_link', $addaquestion); 421 } 422 423 $this->finish_adding_question($questiontype, $questiondata); 424 } 425 426 /** 427 * Set the max mark for a question on the Edit quiz page. 428 * 429 * @When /^I set the max mark for question "(?P<question_name_string>(?:[^"]|\\")*)" to "(?P<new_mark_string>(?:[^"]|\\")*)"$/ 430 * @param string $questionname the name of the question to set the max mark for. 431 * @param string $newmark the mark to set 432 */ 433 public function i_set_the_max_mark_for_quiz_question($questionname, $newmark) { 434 $this->execute('behat_general::click_link', $this->escape(get_string('editmaxmark', 'quiz'))); 435 436 $this->execute('behat_general::wait_until_exists', ["li input[name=maxmark]", "css_element"]); 437 438 $this->execute('behat_general::assert_page_contains_text', $this->escape(get_string('edittitleinstructions'))); 439 440 $this->execute('behat_general::i_type', [$newmark]); 441 $this->execute('behat_general::i_press_named_key', ['', 'enter']); 442 } 443 444 /** 445 * Open the add menu on a given page, or at the end of the Edit quiz page. 446 * @Given /^I open the "(?P<page_n_or_last_string>(?:[^"]|\\")*)" add to quiz menu$/ 447 * @param string $pageorlast either "Page n" or "last". 448 */ 449 public function i_open_the_add_to_quiz_menu_for($pageorlast) { 450 451 if (!$this->running_javascript()) { 452 throw new DriverException('Activities actions menu not available when Javascript is disabled'); 453 } 454 455 if ($pageorlast == 'last') { 456 $xpath = "//div[@class = 'last-add-menu']//a[contains(@data-toggle, 'dropdown') and contains(., 'Add')]"; 457 } else if (preg_match('~Page (\d+)~', $pageorlast, $matches)) { 458 $xpath = "//li[@id = 'page-{$matches[1]}']//a[contains(@data-toggle, 'dropdown') and contains(., 'Add')]"; 459 } else { 460 throw new ExpectationException("The I open the add to quiz menu step must specify either 'Page N' or 'last'.", 461 $this->getSession()); 462 } 463 $this->find('xpath', $xpath)->click(); 464 } 465 466 /** 467 * Check whether a particular question is on a particular page of the quiz on the Edit quiz page. 468 * @Given /^I should see "(?P<question_name>(?:[^"]|\\")*)" on quiz page "(?P<page_number>\d+)"$/ 469 * @param string $questionname the name of the question we are looking for. 470 * @param number $pagenumber the page it should be found on. 471 */ 472 public function i_should_see_on_quiz_page($questionname, $pagenumber) { 473 $xpath = "//li[contains(., '" . $this->escape($questionname) . 474 "')][./preceding-sibling::li[contains(@class, 'pagenumber')][1][contains(., 'Page " . 475 $pagenumber . "')]]"; 476 477 $this->execute('behat_general::should_exist', [$xpath, 'xpath_element']); 478 } 479 480 /** 481 * Check whether a particular question is not on a particular page of the quiz on the Edit quiz page. 482 * @Given /^I should not see "(?P<question_name>(?:[^"]|\\")*)" on quiz page "(?P<page_number>\d+)"$/ 483 * @param string $questionname the name of the question we are looking for. 484 * @param number $pagenumber the page it should be found on. 485 */ 486 public function i_should_not_see_on_quiz_page($questionname, $pagenumber) { 487 $xpath = "//li[contains(., '" . $this->escape($questionname) . 488 "')][./preceding-sibling::li[contains(@class, 'pagenumber')][1][contains(., 'Page " . 489 $pagenumber . "')]]"; 490 491 $this->execute('behat_general::should_not_exist', [$xpath, 'xpath_element']); 492 } 493 494 /** 495 * Check whether one question comes before another on the Edit quiz page. 496 * The two questions must be on the same page. 497 * @Given /^I should see "(?P<first_q_name>(?:[^"]|\\")*)" before "(?P<second_q_name>(?:[^"]|\\")*)" on the edit quiz page$/ 498 * @param string $firstquestionname the name of the question that should come first in order. 499 * @param string $secondquestionname the name of the question that should come immediately after it in order. 500 */ 501 public function i_should_see_before_on_the_edit_quiz_page($firstquestionname, $secondquestionname) { 502 $xpath = "//li[contains(., '" . $this->escape($firstquestionname) . 503 "')]/following-sibling::li" . 504 "[contains(., '" . $this->escape($secondquestionname) . "')]"; 505 506 $this->execute('behat_general::should_exist', [$xpath, 'xpath_element']); 507 } 508 509 /** 510 * Check the number displayed alongside a question on the Edit quiz page. 511 * @Given /^"(?P<question_name>(?:[^"]|\\")*)" should have number "(?P<number>(?:[^"]|\\")*)" on the edit quiz page$/ 512 * @param string $questionname the name of the question we are looking for. 513 * @param number $number the number (or 'i') that should be displayed beside that question. 514 */ 515 public function should_have_number_on_the_edit_quiz_page($questionname, $number) { 516 if ($number !== get_string('infoshort', 'quiz')) { 517 // Logic here copied from edit_renderer, which is not ideal, but necessary. 518 $number = get_string('question') . ' ' . $number; 519 } 520 $xpath = "//li[contains(@class, 'slot') and contains(., '" . $this->escape($questionname) . 521 "')]//span[contains(@class, 'slotnumber') and normalize-space(.) = '" . $this->escape($number) . "']"; 522 $this->execute('behat_general::should_exist', [$xpath, 'xpath_element']); 523 } 524 525 /** 526 * Get the xpath for a partcular add/remove page-break icon. 527 * @param string $addorremoves 'Add' or 'Remove'. 528 * @param string $questionname the name of the question before the icon. 529 * @return string the requried xpath. 530 */ 531 protected function get_xpath_page_break_icon_after_question($addorremoves, $questionname) { 532 return "//li[contains(@class, 'slot') and contains(., '" . $this->escape($questionname) . 533 "')]//a[contains(@class, 'page_split_join') and @title = '" . $addorremoves . " page break']"; 534 } 535 536 /** 537 * Click the add or remove page-break icon after a particular question. 538 * @When /^I click on the "(Add|Remove)" page break icon after question "(?P<question_name>(?:[^"]|\\")*)"$/ 539 * @param string $addorremoves 'Add' or 'Remove'. 540 * @param string $questionname the name of the question before the icon to click. 541 */ 542 public function i_click_on_the_page_break_icon_after_question($addorremoves, $questionname) { 543 $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); 544 545 $this->execute("behat_general::i_click_on", [$xpath, "xpath_element"]); 546 } 547 548 /** 549 * Assert the add or remove page-break icon after a particular question exists. 550 * @When /^the "(Add|Remove)" page break icon after question "(?P<question_name>(?:[^"]|\\")*)" should exist$/ 551 * @param string $addorremoves 'Add' or 'Remove'. 552 * @param string $questionname the name of the question before the icon to click. 553 * @return array of steps. 554 */ 555 public function the_page_break_icon_after_question_should_exist($addorremoves, $questionname) { 556 $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); 557 558 $this->execute('behat_general::should_exist', [$xpath, 'xpath_element']); 559 } 560 561 /** 562 * Assert the add or remove page-break icon after a particular question does not exist. 563 * @When /^the "(Add|Remove)" page break icon after question "(?P<question_name>(?:[^"]|\\")*)" should not exist$/ 564 * @param string $addorremoves 'Add' or 'Remove'. 565 * @param string $questionname the name of the question before the icon to click. 566 * @return array of steps. 567 */ 568 public function the_page_break_icon_after_question_should_not_exist($addorremoves, $questionname) { 569 $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); 570 571 $this->execute('behat_general::should_not_exist', [$xpath, 'xpath_element']); 572 } 573 574 /** 575 * Check the add or remove page-break link after a particular question contains the given parameters in its url. 576 * 577 * @When /^the "(Add|Remove)" page break link after question "(?P<question_name>(?:[^"]|\\")*) should contain:$/ 578 * @When /^the "(Add|Remove)" page break link after question "(?P<question_name>(?:[^"]|\\")*) should contain:"$/ 579 * @param string $addorremoves 'Add' or 'Remove'. 580 * @param string $questionname the name of the question before the icon to click. 581 * @param TableNode $paramdata with data for checking the page break url 582 * @return array of steps. 583 */ 584 public function the_page_break_link_after_question_should_contain($addorremoves, $questionname, $paramdata) { 585 $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); 586 587 $this->execute("behat_general::i_click_on", [$xpath, "xpath_element"]); 588 } 589 590 /** 591 * Set Shuffle for shuffling questions within sections 592 * 593 * @param string $heading the heading of the section to change shuffle for. 594 * 595 * @Given /^I click on shuffle for section "([^"]*)" on the quiz edit page$/ 596 */ 597 public function i_click_on_shuffle_for_section($heading) { 598 $xpath = $this->get_xpath_for_shuffle_checkbox($heading); 599 $checkbox = $this->find('xpath', $xpath); 600 $this->ensure_node_is_visible($checkbox); 601 $checkbox->click(); 602 } 603 604 /** 605 * Check the shuffle checkbox for a particular section. 606 * 607 * @param string $heading the heading of the section to check shuffle for 608 * @param int $value whether the shuffle checkbox should be on or off. 609 * 610 * @Given /^shuffle for section "([^"]*)" should be "(On|Off)" on the quiz edit page$/ 611 */ 612 public function shuffle_for_section_should_be($heading, $value) { 613 $xpath = $this->get_xpath_for_shuffle_checkbox($heading); 614 $checkbox = $this->find('xpath', $xpath); 615 $this->ensure_node_is_visible($checkbox); 616 if ($value == 'On' && !$checkbox->isChecked()) { 617 $msg = "Shuffle for section '$heading' is not checked, but you are expecting it to be checked ($value). " . 618 "Check the line with: \nshuffle for section \"$heading\" should be \"$value\" on the quiz edit page" . 619 "\nin your behat script"; 620 throw new ExpectationException($msg, $this->getSession()); 621 } else if ($value == 'Off' && $checkbox->isChecked()) { 622 $msg = "Shuffle for section '$heading' is checked, but you are expecting it not to be ($value). " . 623 "Check the line with: \nshuffle for section \"$heading\" should be \"$value\" on the quiz edit page" . 624 "\nin your behat script"; 625 throw new ExpectationException($msg, $this->getSession()); 626 } 627 } 628 629 /** 630 * Return the xpath for shuffle checkbox in section heading 631 * @param string $heading 632 * @return string 633 */ 634 protected function get_xpath_for_shuffle_checkbox($heading) { 635 return "//div[contains(@class, 'section-heading') and contains(., '" . $this->escape($heading) . 636 "')]//input[@type = 'checkbox']"; 637 } 638 639 /** 640 * Move a question on the Edit quiz page by first clicking on the Move icon, 641 * then clicking one of the "After ..." links. 642 * @When /^I move "(?P<question_name>(?:[^"]|\\")*)" to "(?P<target>(?:[^"]|\\")*)" in the quiz by clicking the move icon$/ 643 * @param string $questionname the name of the question we are looking for. 644 * @param string $target the target place to move to. One of the links in the pop-up like 645 * "After Page 1" or "After Question N". 646 */ 647 public function i_move_question_after_item_by_clicking_the_move_icon($questionname, $target) { 648 $iconxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) . 649 "')]//span[contains(@class, 'editing_move')]"; 650 651 $this->execute("behat_general::i_click_on", [$iconxpath, "xpath_element"]); 652 $this->execute("behat_general::i_click_on", [$this->escape($target), "button"]); 653 } 654 655 /** 656 * Move a question on the Edit quiz page by dragging a given question on top of another item. 657 * @When /^I move "(?P<question_name>(?:[^"]|\\")*)" to "(?P<target>(?:[^"]|\\")*)" in the quiz by dragging$/ 658 * @param string $questionname the name of the question we are looking for. 659 * @param string $target the target place to move to. Ether a question name, or "Page N" 660 */ 661 public function i_move_question_after_item_by_dragging($questionname, $target) { 662 $iconxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) . 663 "')]//span[contains(@class, 'editing_move')]//img"; 664 $destinationxpath = "//li[contains(@class, ' slot ') or contains(@class, 'pagenumber ')]" . 665 "[contains(., '" . $this->escape($target) . "')]"; 666 667 $this->execute('behat_general::i_drag_and_i_drop_it_in', 668 [$iconxpath, 'xpath_element', $destinationxpath, 'xpath_element'] 669 ); 670 } 671 672 /** 673 * Delete a question on the Edit quiz page by first clicking on the Delete icon, 674 * then clicking one of the "After ..." links. 675 * @When /^I delete "(?P<question_name>(?:[^"]|\\")*)" in the quiz by clicking the delete icon$/ 676 * @param string $questionname the name of the question we are looking for. 677 * @return array of steps. 678 */ 679 public function i_delete_question_by_clicking_the_delete_icon($questionname) { 680 $slotxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) . 681 "')]"; 682 $deletexpath = "//a[contains(@class, 'editing_delete')]"; 683 684 $this->execute("behat_general::i_click_on", [$slotxpath . $deletexpath, "xpath_element"]); 685 686 $this->execute('behat_general::i_click_on_in_the', 687 ['Yes', "button", "Confirm", "dialogue"] 688 ); 689 } 690 691 /** 692 * Set the section heading for a given section on the Edit quiz page 693 * 694 * @When /^I change quiz section heading "(?P<section_name_string>(?:[^"]|\\")*)" to "(?P<new_section_heading_string>(?:[^"]|\\")*)"$/ 695 * @param string $sectionname the heading to change. 696 * @param string $sectionheading the new heading to set. 697 */ 698 public function i_set_the_section_heading_for($sectionname, $sectionheading) { 699 // Empty section headings will have a default names of "Untitled heading". 700 if (empty($sectionname)) { 701 $sectionname = get_string('sectionnoname', 'quiz'); 702 } 703 $this->execute('behat_general::click_link', $this->escape("Edit heading '{$sectionname}'")); 704 705 $this->execute('behat_general::assert_page_contains_text', $this->escape(get_string('edittitleinstructions'))); 706 707 $this->execute('behat_general::i_press_named_key', ['', 'backspace']); 708 $this->execute('behat_general::i_type', [$sectionheading]); 709 $this->execute('behat_general::i_press_named_key', ['', 'enter']); 710 } 711 712 /** 713 * Check that a given question comes after a given section heading in the 714 * quiz navigation block. 715 * 716 * @Then /^I should see question "(?P<questionnumber>(?:[^"]|\\")*)" in section "(?P<section_heading_string>(?:[^"]|\\")*)" in the quiz navigation$/ 717 * @param string $questionnumber the number of the question to check. 718 * @param string $sectionheading which section heading it should appear after. 719 */ 720 public function i_should_see_question_in_section_in_the_quiz_navigation($questionnumber, $sectionheading) { 721 722 // Using xpath literal to avoid quotes problems. 723 $questionnumberliteral = behat_context_helper::escape($questionnumber); 724 $headingliteral = behat_context_helper::escape($sectionheading); 725 726 // Split in two checkings to give more feedback in case of exception. 727 $exception = new ExpectationException('Question "' . $questionnumber . '" is not in section "' . 728 $sectionheading . '" in the quiz navigation.', $this->getSession()); 729 $xpath = "//*[@id = 'mod_quiz_navblock']//*[contains(concat(' ', normalize-space(@class), ' '), ' qnbutton ') and " . 730 "contains(., {$questionnumberliteral}) and contains(preceding-sibling::h3[1], {$headingliteral})]"; 731 $this->find('xpath', $xpath, $exception); 732 } 733 734 /** 735 * Helper used by user_has_attempted_with_responses, 736 * user_has_started_an_attempt_at_quiz_with_details, etc. 737 * 738 * @param TableNode $attemptinfo data table from the Behat step 739 * @return array with two elements, $forcedrandomquestions, $forcedvariants, 740 * that can be passed to $quizgenerator->create_attempt. 741 */ 742 protected function extract_forced_randomisation_from_attempt_info(TableNode $attemptinfo) { 743 global $DB; 744 745 $forcedrandomquestions = []; 746 $forcedvariants = []; 747 foreach ($attemptinfo->getHash() as $slotinfo) { 748 if (empty($slotinfo['slot'])) { 749 throw new ExpectationException('When simulating a quiz attempt, ' . 750 'the slot column is required.', $this->getSession()); 751 } 752 753 if (!empty($slotinfo['actualquestion'])) { 754 $forcedrandomquestions[$slotinfo['slot']] = $DB->get_field('question', 'id', 755 ['name' => $slotinfo['actualquestion']], MUST_EXIST); 756 } 757 758 if (!empty($slotinfo['variant'])) { 759 $forcedvariants[$slotinfo['slot']] = (int) $slotinfo['variant']; 760 } 761 } 762 return [$forcedrandomquestions, $forcedvariants]; 763 } 764 765 /** 766 * Helper used by user_has_attempted_with_responses, user_has_checked_answers_in_their_attempt_at_quiz, 767 * user_has_input_answers_in_their_attempt_at_quiz, etc. 768 * 769 * @param TableNode $attemptinfo data table from the Behat step 770 * @return array of responses that can be passed to $quizgenerator->submit_responses. 771 */ 772 protected function extract_responses_from_attempt_info(TableNode $attemptinfo) { 773 $responses = []; 774 foreach ($attemptinfo->getHash() as $slotinfo) { 775 if (empty($slotinfo['slot'])) { 776 throw new ExpectationException('When simulating a quiz attempt, ' . 777 'the slot column is required.', $this->getSession()); 778 } 779 if (!array_key_exists('response', $slotinfo)) { 780 throw new ExpectationException('When simulating a quiz attempt, ' . 781 'the response column is required.', $this->getSession()); 782 } 783 $responses[$slotinfo['slot']] = $slotinfo['response']; 784 } 785 return $responses; 786 } 787 788 /** 789 * Attempt a quiz. 790 * 791 * The first row should be column names: 792 * | slot | actualquestion | variant | response | 793 * The first two of those are required. The others are optional. 794 * 795 * slot The slot 796 * actualquestion This column is optional, and is only needed if the quiz contains 797 * random questions. If so, this will let you control which actual 798 * question gets picked when this slot is 'randomised' at the 799 * start of the attempt. If you don't specify, then one will be picked 800 * at random (which might make the response meaningless). 801 * Give the question name. 802 * variant This column is similar, and also options. It is only needed if 803 * the question that ends up in this slot returns something greater 804 * than 1 for $question->get_num_variants(). Like with actualquestion, 805 * if you specify a value here it is used the fix the 'random' choice 806 * made when the quiz is started. 807 * response The response that was submitted. How this is interpreted depends on 808 * the question type. It gets passed to 809 * {@link core_question_generator::get_simulated_post_data_for_question_attempt()} 810 * and therefore to the un_summarise_response method of the question to decode. 811 * 812 * Then there should be a number of rows of data, one for each question you want to add. 813 * There is no need to supply answers to all questions. If so, other qusetions will be 814 * left unanswered. 815 * 816 * @param string $username the username of the user that will attempt. 817 * @param string $quizname the name of the quiz the user will attempt. 818 * @param TableNode $attemptinfo information about the questions to add, as above. 819 * @Given /^user "([^"]*)" has attempted "([^"]*)" with responses:$/ 820 */ 821 public function user_has_attempted_with_responses($username, $quizname, TableNode $attemptinfo) { 822 global $DB; 823 824 /** @var mod_quiz_generator $quizgenerator */ 825 $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); 826 827 $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); 828 $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); 829 830 list($forcedrandomquestions, $forcedvariants) = 831 $this->extract_forced_randomisation_from_attempt_info($attemptinfo); 832 $responses = $this->extract_responses_from_attempt_info($attemptinfo); 833 834 $this->set_user($user); 835 836 $attempt = $quizgenerator->create_attempt($quizid, $user->id, 837 $forcedrandomquestions, $forcedvariants); 838 839 $quizgenerator->submit_responses($attempt->id, $responses, false, true); 840 841 $this->set_user(); 842 } 843 844 /** 845 * Start a quiz attempt without answers. 846 * 847 * @param string $username the username of the user that will attempt. 848 * @param string $quizname the name of the quiz the user will attempt. 849 * @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*)"$/ 850 */ 851 public function user_has_started_an_attempt_at_quiz($username, $quizname) { 852 global $DB; 853 854 /** @var mod_quiz_generator $quizgenerator */ 855 $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); 856 857 $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); 858 $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); 859 $this->set_user($user); 860 $quizgenerator->create_attempt($quizid, $user->id); 861 $this->set_user(); 862 } 863 864 /** 865 * Start a quiz attempt without answers. 866 * 867 * The supplied data table for have a row for each slot where you want 868 * to force either which random question was chose, or which random variant 869 * was used, as for {@link user_has_attempted_with_responses()} above. 870 * 871 * @param string $username the username of the user that will attempt. 872 * @param string $quizname the name of the quiz the user will attempt. 873 * @param TableNode $attemptinfo information about the questions to add, as above. 874 * @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*)" randomised as follows:$/ 875 */ 876 public function user_has_started_an_attempt_at_quiz_with_details($username, $quizname, TableNode $attemptinfo) { 877 global $DB; 878 879 /** @var mod_quiz_generator $quizgenerator */ 880 $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); 881 882 $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); 883 $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); 884 885 list($forcedrandomquestions, $forcedvariants) = 886 $this->extract_forced_randomisation_from_attempt_info($attemptinfo); 887 888 $this->set_user($user); 889 890 $quizgenerator->create_attempt($quizid, $user->id, 891 $forcedrandomquestions, $forcedvariants); 892 893 $this->set_user(); 894 } 895 896 /** 897 * Input answers to particular questions an existing quiz attempt, without 898 * simulating a click of the 'Check' button, if any. 899 * 900 * Then there should be a number of rows of data, with two columns slot and response, 901 * as for {@link user_has_attempted_with_responses()} above. 902 * There is no need to supply answers to all questions. If so, other questions will be 903 * left unanswered. 904 * 905 * @param string $username the username of the user that will attempt. 906 * @param string $quizname the name of the quiz the user will attempt. 907 * @param TableNode $attemptinfo information about the questions to add, as above. 908 * @throws \Behat\Mink\Exception\ExpectationException 909 * @Given /^user "([^"]*)" has input answers in their attempt at quiz "([^"]*)":$/ 910 */ 911 public function user_has_input_answers_in_their_attempt_at_quiz($username, $quizname, TableNode $attemptinfo) { 912 global $DB; 913 914 /** @var mod_quiz_generator $quizgenerator */ 915 $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); 916 917 $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); 918 $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); 919 920 $responses = $this->extract_responses_from_attempt_info($attemptinfo); 921 922 $this->set_user($user); 923 924 $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true); 925 $quizgenerator->submit_responses(key($attempts), $responses, false, false); 926 927 $this->set_user(); 928 } 929 930 /** 931 * Submit answers to questions an existing quiz attempt, with a simulated click on the 'Check' button. 932 * 933 * This step should only be used with question behaviours that have have 934 * a 'Check' button. Those include Interactive with multiple tires, Immediate feedback 935 * and Immediate feedback with CBM. 936 * 937 * Then there should be a number of rows of data, with two columns slot and response, 938 * as for {@link user_has_attempted_with_responses()} above. 939 * There is no need to supply answers to all questions. If so, other questions will be 940 * left unanswered. 941 * 942 * @param string $username the username of the user that will attempt. 943 * @param string $quizname the name of the quiz the user will attempt. 944 * @param TableNode $attemptinfo information about the questions to add, as above. 945 * @throws \Behat\Mink\Exception\ExpectationException 946 * @Given /^user "([^"]*)" has checked answers in their attempt at quiz "([^"]*)":$/ 947 */ 948 public function user_has_checked_answers_in_their_attempt_at_quiz($username, $quizname, TableNode $attemptinfo) { 949 global $DB; 950 951 /** @var mod_quiz_generator $quizgenerator */ 952 $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); 953 954 $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); 955 $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); 956 957 $responses = $this->extract_responses_from_attempt_info($attemptinfo); 958 959 $this->set_user($user); 960 961 $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true); 962 $quizgenerator->submit_responses(key($attempts), $responses, true, false); 963 964 $this->set_user(); 965 } 966 967 /** 968 * Finish an existing quiz attempt. 969 * 970 * @param string $username the username of the user that will attempt. 971 * @param string $quizname the name of the quiz the user will attempt. 972 * @Given /^user "([^"]*)" has finished an attempt at quiz "([^"]*)"$/ 973 */ 974 public function user_has_finished_an_attempt_at_quiz($username, $quizname) { 975 global $DB; 976 977 $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); 978 $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); 979 980 $this->set_user($user); 981 982 $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true); 983 $attemptobj = quiz_attempt::create(key($attempts)); 984 $attemptobj->process_finish(time(), true); 985 986 $this->set_user(); 987 } 988 989 /** 990 * Finish an existing quiz attempt. 991 * 992 * @param string $quizname the name of the quiz the user will attempt. 993 * @param string $username the username of the user that will attempt. 994 * @Given the attempt at :quizname by :username was never submitted 995 */ 996 public function attempt_was_abandoned($quizname, $username) { 997 global $DB; 998 999 $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); 1000 $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); 1001 1002 $this->set_user($user); 1003 1004 $attempt = quiz_get_user_attempt_unfinished($quizid, $user->id); 1005 if (!$attempt) { 1006 throw new coding_exception("No in-progress attempt found for $username and quiz $quizname."); 1007 } 1008 $attemptobj = quiz_attempt::create($attempt->id); 1009 $attemptobj->process_abandon(time(), false); 1010 1011 $this->set_user(); 1012 } 1013 1014 /** 1015 * Return a list of the exact named selectors for the component. 1016 * 1017 * @return behat_component_named_selector[] 1018 */ 1019 public static function get_exact_named_selectors(): array { 1020 return [ 1021 new behat_component_named_selector('Edit slot', 1022 ["//li[contains(@class,'qtype')]//span[@class='slotnumber' and contains(., %locator%)]/.."]) 1023 ]; 1024 } 1025 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body