See Release Notes
Long Term Support Release
Differences Between: [Versions 311 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 declare(strict_types=1); 18 19 namespace mod_quiz; 20 21 use advanced_testcase; 22 use cm_info; 23 use core_completion\cm_completion_details; 24 use grade_item; 25 use mod_quiz\completion\custom_completion; 26 use question_engine; 27 use quiz; 28 use quiz_attempt; 29 30 defined('MOODLE_INTERNAL') || die(); 31 32 global $CFG; 33 require_once($CFG->libdir . '/completionlib.php'); 34 35 /** 36 * Class for unit testing mod_quiz/custom_completion. 37 * 38 * @package mod_quiz 39 * @copyright 2021 Shamim Rezaie <shamim@moodle.com> 40 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 41 * @coversDefaultClass \mod_quiz\completion\custom_completion 42 */ 43 class custom_completion_test extends advanced_testcase { 44 45 /** 46 * Setup function for all tests. 47 * 48 * @param array $completionoptions ['nbstudents'] => int, ['qtype'] => string, ['quizoptions'] => array 49 * @return array [$students, $quiz, $cm, $litecm] 50 */ 51 private function setup_quiz_for_testing_completion(array $completionoptions): array { 52 global $CFG, $DB; 53 54 $this->resetAfterTest(true); 55 56 // Enable completion before creating modules, otherwise the completion data is not written in DB. 57 $CFG->enablecompletion = true; 58 59 // Create a course and students. 60 $studentrole = $DB->get_record('role', ['shortname' => 'student']); 61 $course = $this->getDataGenerator()->create_course(['enablecompletion' => true]); 62 $students = []; 63 $sumgrades = $completionoptions['sumgrades'] ?? 1; 64 $nbquestions = $completionoptions['nbquestions'] ?? 1; 65 for ($i = 0; $i < $completionoptions['nbstudents']; $i++) { 66 $students[$i] = $this->getDataGenerator()->create_user(); 67 $this->assertTrue($this->getDataGenerator()->enrol_user($students[$i]->id, $course->id, $studentrole->id)); 68 } 69 70 // Make a quiz. 71 $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); 72 $data = array_merge([ 73 'course' => $course->id, 74 'grade' => 100.0, 75 'questionsperpage' => 0, 76 'sumgrades' => $sumgrades, 77 'completion' => COMPLETION_TRACKING_AUTOMATIC 78 ], $completionoptions['quizoptions']); 79 $quiz = $quizgenerator->create_instance($data); 80 $litecm = get_coursemodule_from_id('quiz', $quiz->cmid); 81 $cm = cm_info::create($litecm); 82 83 // Create a question. 84 $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); 85 86 $cat = $questiongenerator->create_question_category(); 87 for ($i = 0; $i < $nbquestions; $i++) { 88 $overrideparams = ['category' => $cat->id]; 89 if (isset($completionoptions['questiondefaultmarks'][$i])) { 90 $overrideparams['defaultmark'] = $completionoptions['questiondefaultmarks'][$i]; 91 } 92 $question = $questiongenerator->create_question($completionoptions['qtype'], null, $overrideparams); 93 quiz_add_quiz_question($question->id, $quiz); 94 } 95 96 // Set grade to pass. 97 $item = grade_item::fetch(['courseid' => $course->id, 'itemtype' => 'mod', 'itemmodule' => 'quiz', 98 'iteminstance' => $quiz->id, 'outcomeid' => null]); 99 $item->gradepass = 80; 100 $item->update(); 101 return [ 102 $students, 103 $quiz, 104 $cm, 105 $litecm 106 ]; 107 } 108 109 /** 110 * Helper function for tests. 111 * Starts an attempt, processes responses and finishes the attempt. 112 * 113 * @param array $attemptoptions ['quiz'] => object, ['student'] => object, ['tosubmit'] => array, ['attemptnumber'] => int 114 */ 115 private function do_attempt_quiz(array $attemptoptions) { 116 $quizobj = quiz::create($attemptoptions['quiz']->id); 117 118 // Start the passing attempt. 119 $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); 120 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); 121 122 $timenow = time(); 123 $attempt = quiz_create_attempt($quizobj, $attemptoptions['attemptnumber'], false, $timenow, false, 124 $attemptoptions['student']->id); 125 quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptoptions['attemptnumber'], $timenow); 126 quiz_attempt_save_started($quizobj, $quba, $attempt); 127 128 // Process responses from the student. 129 $attemptobj = quiz_attempt::create($attempt->id); 130 $attemptobj->process_submitted_actions($timenow, false, $attemptoptions['tosubmit']); 131 132 // Finish the attempt. 133 $attemptobj = quiz_attempt::create($attempt->id); 134 $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); 135 $attemptobj->process_finish($timenow, false); 136 } 137 138 /** 139 * Test checking the completion state of a quiz base on core's completionpassgrade criteria. 140 * The quiz requires a passing grade to be completed. 141 */ 142 public function test_completionpass() { 143 list($students, $quiz, $cm) = $this->setup_quiz_for_testing_completion([ 144 'nbstudents' => 2, 145 'qtype' => 'numerical', 146 'quizoptions' => [ 147 'completionusegrade' => 1, 148 'completionpassgrade' => 1 149 ] 150 ]); 151 152 list($passstudent, $failstudent) = $students; 153 154 // Do a passing attempt. 155 $this->do_attempt_quiz([ 156 'quiz' => $quiz, 157 'student' => $passstudent, 158 'attemptnumber' => 1, 159 'tosubmit' => [1 => ['answer' => '3.14']] 160 ]); 161 162 $completioninfo = new \completion_info($cm->get_course()); 163 $completiondetails = new cm_completion_details($completioninfo, $cm, (int) $passstudent->id); 164 165 // Check the results. 166 $this->assertEquals(COMPLETION_COMPLETE_PASS, $completiondetails->get_details()['completionpassgrade']->status); 167 $this->assertEquals( 168 'Receive a passing grade', 169 $completiondetails->get_details()['completionpassgrade']->description 170 ); 171 172 // Do a failing attempt. 173 $this->do_attempt_quiz([ 174 'quiz' => $quiz, 175 'student' => $failstudent, 176 'attemptnumber' => 1, 177 'tosubmit' => [1 => ['answer' => '0']] 178 ]); 179 180 $completiondetails = new cm_completion_details($completioninfo, $cm, (int) $failstudent->id); 181 182 // Check the results. 183 $this->assertEquals(COMPLETION_COMPLETE_FAIL, $completiondetails->get_details()['completionpassgrade']->status); 184 $this->assertEquals( 185 'Receive a passing grade', 186 $completiondetails->get_details()['completionpassgrade']->description 187 ); 188 } 189 190 /** 191 * Test checking the completion state of a quiz. 192 * To be completed, this quiz requires either a passing grade or for all attempts to be used up. 193 * 194 * @covers ::get_state 195 * @covers ::get_custom_rule_descriptions 196 */ 197 public function test_completionexhausted() { 198 list($students, $quiz, $cm) = $this->setup_quiz_for_testing_completion([ 199 'nbstudents' => 2, 200 'qtype' => 'numerical', 201 'quizoptions' => [ 202 'attempts' => 2, 203 'completionusegrade' => 1, 204 'completionpassgrade' => 1, 205 'completionattemptsexhausted' => 1 206 ] 207 ]); 208 209 list($passstudent, $exhauststudent) = $students; 210 211 // Start a passing attempt. 212 $this->do_attempt_quiz([ 213 'quiz' => $quiz, 214 'student' => $passstudent, 215 'attemptnumber' => 1, 216 'tosubmit' => [1 => ['answer' => '3.14']] 217 ]); 218 219 $completioninfo = new \completion_info($cm->get_course()); 220 221 // Check the results. Quiz is completed by $passstudent because of passing grade. 222 $studentid = (int) $passstudent->id; 223 $customcompletion = new custom_completion($cm, $studentid, $completioninfo->get_core_completion_state($cm, $studentid)); 224 $this->assertArrayHasKey('completionpassorattemptsexhausted', $cm->customdata['customcompletionrules']); 225 $this->assertEquals(COMPLETION_COMPLETE, $customcompletion->get_state('completionpassorattemptsexhausted')); 226 $this->assertEquals( 227 'Receive a pass grade or complete all available attempts', 228 $customcompletion->get_custom_rule_descriptions()['completionpassorattemptsexhausted'] 229 ); 230 231 // Do a failing attempt. 232 $this->do_attempt_quiz([ 233 'quiz' => $quiz, 234 'student' => $exhauststudent, 235 'attemptnumber' => 1, 236 'tosubmit' => [1 => ['answer' => '0']] 237 ]); 238 239 // Check the results. Quiz is not completed by $exhauststudent yet because of failing grade and of remaining attempts. 240 $studentid = (int) $exhauststudent->id; 241 $customcompletion = new custom_completion($cm, $studentid, $completioninfo->get_core_completion_state($cm, $studentid)); 242 $this->assertArrayHasKey('completionpassorattemptsexhausted', $cm->customdata['customcompletionrules']); 243 $this->assertEquals(COMPLETION_INCOMPLETE, $customcompletion->get_state('completionpassorattemptsexhausted')); 244 $this->assertEquals( 245 'Receive a pass grade or complete all available attempts', 246 $customcompletion->get_custom_rule_descriptions()['completionpassorattemptsexhausted'] 247 ); 248 249 // Do a second failing attempt. 250 $this->do_attempt_quiz([ 251 'quiz' => $quiz, 252 'student' => $exhauststudent, 253 'attemptnumber' => 2, 254 'tosubmit' => [1 => ['answer' => '0']] 255 ]); 256 257 // Check the results. Quiz is completed by $exhauststudent because there are no remaining attempts. 258 $customcompletion = new custom_completion($cm, $studentid, $completioninfo->get_core_completion_state($cm, $studentid)); 259 $this->assertArrayHasKey('completionpassorattemptsexhausted', $cm->customdata['customcompletionrules']); 260 $this->assertEquals(COMPLETION_COMPLETE, $customcompletion->get_state('completionpassorattemptsexhausted')); 261 $this->assertEquals( 262 'Receive a pass grade or complete all available attempts', 263 $customcompletion->get_custom_rule_descriptions()['completionpassorattemptsexhausted'] 264 ); 265 266 } 267 268 /** 269 * Test checking the completion state of a quiz. 270 * To be completed, this quiz requires a minimum number of attempts. 271 * 272 * @covers ::get_state 273 * @covers ::get_custom_rule_descriptions 274 */ 275 public function test_completionminattempts() { 276 list($students, $quiz, $cm) = $this->setup_quiz_for_testing_completion([ 277 'nbstudents' => 1, 278 'qtype' => 'essay', 279 'quizoptions' => [ 280 'completionminattemptsenabled' => 1, 281 'completionminattempts' => 2 282 ] 283 ]); 284 285 list($student) = $students; 286 287 // Do a first attempt. 288 $this->do_attempt_quiz([ 289 'quiz' => $quiz, 290 'student' => $student, 291 'attemptnumber' => 1, 292 'tosubmit' => [1 => ['answer' => 'Lorem ipsum.', 'answerformat' => '1']] 293 ]); 294 295 // Check the results. Quiz is not completed yet because only one attempt was done. 296 $customcompletion = new custom_completion($cm, (int) $student->id); 297 $this->assertArrayHasKey('completionminattempts', $cm->customdata['customcompletionrules']); 298 $this->assertEquals(COMPLETION_INCOMPLETE, $customcompletion->get_state('completionminattempts')); 299 $this->assertEquals( 300 'Make attempts: 2', 301 $customcompletion->get_custom_rule_descriptions()['completionminattempts'] 302 ); 303 304 // Do a second attempt. 305 $this->do_attempt_quiz([ 306 'quiz' => $quiz, 307 'student' => $student, 308 'attemptnumber' => 2, 309 'tosubmit' => [1 => ['answer' => 'Lorem ipsum.', 'answerformat' => '1']] 310 ]); 311 312 // Check the results. Quiz is completed by $student because two attempts were done. 313 $customcompletion = new custom_completion($cm, (int) $student->id); 314 $this->assertArrayHasKey('completionminattempts', $cm->customdata['customcompletionrules']); 315 $this->assertEquals(COMPLETION_COMPLETE, $customcompletion->get_state('completionminattempts')); 316 $this->assertEquals( 317 'Make attempts: 2', 318 $customcompletion->get_custom_rule_descriptions()['completionminattempts'] 319 ); 320 } 321 322 /** 323 * Test for get_defined_custom_rules(). 324 * 325 * @covers ::get_defined_custom_rules 326 */ 327 public function test_get_defined_custom_rules() { 328 $rules = custom_completion::get_defined_custom_rules(); 329 $this->assertCount(2, $rules); 330 $this->assertEquals( 331 ['completionpassorattemptsexhausted', 'completionminattempts'], 332 $rules 333 ); 334 } 335 336 /** 337 * Test update moduleinfo. 338 * 339 * @covers \update_moduleinfo 340 */ 341 public function test_update_moduleinfo() { 342 $this->setAdminUser(); 343 // We need lite cm object not a full cm because update_moduleinfo is not allow some properties to be updated. 344 list($students, $quiz, $cm, $litecm) = $this->setup_quiz_for_testing_completion([ 345 'nbstudents' => 1, 346 'qtype' => 'numerical', 347 'nbquestions' => 2, 348 'sumgrades' => 100, 349 'questiondefaultmarks' => [20, 80], 350 'quizoptions' => [ 351 'completionusegrade' => 1, 352 'completionpassgrade' => 1, 353 'completionview' => 0, 354 ] 355 ]); 356 $course = $cm->get_course(); 357 358 list($student) = $students; 359 // Do a first attempt with a pass marks = 20. 360 $this->do_attempt_quiz([ 361 'quiz' => $quiz, 362 'student' => $student, 363 'attemptnumber' => 1, 364 'tosubmit' => [1 => ['answer' => '3.14']] 365 ]); 366 $completioninfo = new \completion_info($course); 367 $cminfo = \cm_info::create($cm); 368 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); 369 370 // Check the results. Completion is fail because gradepass = 80. 371 $this->assertEquals(COMPLETION_COMPLETE_FAIL, $completiondetails->get_details()['completionpassgrade']->status); 372 $this->assertEquals( 373 'Receive a passing grade', 374 $completiondetails->get_details()['completionpassgrade']->description 375 ); 376 377 // Update quiz with passgrade = 20 and use highest grade to calculate. 378 $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 20, QUIZ_GRADEHIGHEST); 379 update_moduleinfo($litecm, $moduleinfo, $course, null); 380 381 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); 382 383 // Check the results. Completion is pass. 384 $this->assertEquals(COMPLETION_COMPLETE_PASS, $completiondetails->get_details()['completionpassgrade']->status); 385 $this->assertEquals( 386 'Receive a passing grade', 387 $completiondetails->get_details()['completionpassgrade']->description 388 ); 389 390 // Do a second attempt with pass marks = 80. 391 $this->do_attempt_quiz([ 392 'quiz' => $quiz, 393 'student' => $student, 394 'attemptnumber' => 2, 395 'tosubmit' => [2 => ['answer' => '3.14']] 396 ]); 397 398 // Update quiz with gradepass = 80 and use highest grade to calculate completion. 399 $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 80, QUIZ_GRADEHIGHEST); 400 update_moduleinfo($litecm, $moduleinfo, $course, null); 401 402 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); 403 404 // Check the results. Completion is pass. 405 $this->assertEquals(COMPLETION_COMPLETE_PASS, $completiondetails->get_details()['completionpassgrade']->status); 406 $this->assertEquals( 407 'Receive a passing grade', 408 $completiondetails->get_details()['completionpassgrade']->description 409 ); 410 411 // Update quiz with gradepass = 80 and use average grade to calculate completion. 412 $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 80, QUIZ_GRADEAVERAGE); 413 update_moduleinfo($litecm, $moduleinfo, $course, null); 414 415 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); 416 417 // Check the results. Completion is fail because student grade = 50. 418 $this->assertEquals(COMPLETION_COMPLETE_FAIL, $completiondetails->get_details()['completionpassgrade']->status); 419 $this->assertEquals( 420 'Receive a passing grade', 421 $completiondetails->get_details()['completionpassgrade']->description 422 ); 423 424 // Update quiz with gradepass = 50 and use average grade to calculate completion. 425 $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 50, QUIZ_GRADEAVERAGE); 426 update_moduleinfo($litecm, $moduleinfo, $course, null); 427 428 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); 429 430 // Check the results. Completion is pass. 431 $this->assertEquals(COMPLETION_COMPLETE_PASS, $completiondetails->get_details()['completionpassgrade']->status); 432 $this->assertEquals( 433 'Receive a passing grade', 434 $completiondetails->get_details()['completionpassgrade']->description 435 ); 436 437 // Update quiz with gradepass = 50 and use first attempt grade to calculate completion. 438 $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 50, QUIZ_ATTEMPTFIRST); 439 update_moduleinfo($litecm, $moduleinfo, $course, null); 440 441 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); 442 443 // Check the results. Completion is fail. 444 $this->assertEquals(COMPLETION_COMPLETE_FAIL, $completiondetails->get_details()['completionpassgrade']->status); 445 $this->assertEquals( 446 'Receive a passing grade', 447 $completiondetails->get_details()['completionpassgrade']->description 448 ); 449 // Update quiz with gradepass = 50 and use last attempt grade to calculate completion. 450 $moduleinfo = $this->prepare_module_info($cm, $quiz, $course, 50, QUIZ_ATTEMPTLAST); 451 update_moduleinfo($litecm, $moduleinfo, $course, null); 452 453 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id); 454 455 // Check the results. Completion is fail. 456 $this->assertEquals(COMPLETION_COMPLETE_PASS, $completiondetails->get_details()['completionpassgrade']->status); 457 $this->assertEquals( 458 'Receive a passing grade', 459 $completiondetails->get_details()['completionpassgrade']->description 460 ); 461 } 462 463 /** 464 * Set up moduleinfo object sample data for quiz instance. 465 * 466 * @param object $cm course-module instance 467 * @param object $quiz quiz instance data. 468 * @param object $course Course related data. 469 * @param int $gradepass Grade to pass and completed completion. 470 * @param string $grademethod grade attempt method. 471 * @return \stdClass 472 */ 473 private function prepare_module_info(object $cm, object $quiz, object $course, int $gradepass, string $grademethod): \stdClass { 474 $grouping = $this->getDataGenerator()->create_grouping(['courseid' => $course->id]); 475 // Module test values. 476 $moduleinfo = new \stdClass(); 477 $moduleinfo->coursemodule = $cm->id; 478 $moduleinfo->section = 1; 479 $moduleinfo->course = $course->id; 480 $moduleinfo->groupingid = $grouping->id; 481 $draftideditor = 0; 482 file_prepare_draft_area($draftideditor, null, null, null, null); 483 $moduleinfo->introeditor = ['text' => 'This is a module', 'format' => FORMAT_HTML, 'itemid' => $draftideditor]; 484 $moduleinfo->modulename = 'quiz'; 485 $moduleinfo->quizpassword = ''; 486 $moduleinfo->cmidnumber = ''; 487 $moduleinfo->marksopen = 1; 488 $moduleinfo->visible = 1; 489 $moduleinfo->visibleoncoursepage = 1; 490 $moduleinfo->completion = COMPLETION_TRACKING_AUTOMATIC; 491 $moduleinfo->completionview = COMPLETION_VIEW_NOT_REQUIRED; 492 $moduleinfo->name = $quiz->name; 493 $moduleinfo->timeopen = $quiz->timeopen; 494 $moduleinfo->timeclose = $quiz->timeclose; 495 $moduleinfo->timelimit = $quiz->timelimit; 496 $moduleinfo->graceperiod = $quiz->graceperiod; 497 $moduleinfo->decimalpoints = $quiz->decimalpoints; 498 $moduleinfo->questiondecimalpoints = $quiz->questiondecimalpoints; 499 $moduleinfo->gradepass = $gradepass; 500 $moduleinfo->grademethod = $grademethod; 501 502 return $moduleinfo; 503 } 504 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body