MDL-77564 Quiz display options: Hide or show the grade information
[moodle.git] / mod / quiz / tests / custom_completion_test.php
blob5d8084d00665178601bb7634ed21a506f9ba7153
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.
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/>.
17 declare(strict_types=1);
19 namespace mod_quiz;
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 mod_quiz\quiz_settings;
28 use stdClass;
30 defined('MOODLE_INTERNAL') || die();
32 global $CFG;
33 require_once($CFG->libdir . '/completionlib.php');
35 /**
36 * Class for unit testing mod_quiz/custom_completion.
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
43 class custom_completion_test extends advanced_testcase {
45 /**
46 * Setup function for all tests.
48 * @param array $completionoptions ['nbstudents'] => int, ['qtype'] => string, ['quizoptions'] => array
49 * @return array [$students, $quiz, $cm, $litecm]
51 private function setup_quiz_for_testing_completion(array $completionoptions): array {
52 global $CFG, $DB;
54 $this->resetAfterTest(true);
56 // Enable completion before creating modules, otherwise the completion data is not written in DB.
57 $CFG->enablecompletion = true;
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));
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);
83 // Create a question.
84 $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
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];
92 $question = $questiongenerator->create_question($completionoptions['qtype'], null, $overrideparams);
93 quiz_add_quiz_question($question->id, $quiz);
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
110 * Helper function for tests.
111 * Starts an attempt, processes responses and finishes the attempt.
113 * @param array $attemptoptions ['quiz'] => object, ['student'] => object, ['tosubmit'] => array, ['attemptnumber'] => int
115 private function do_attempt_quiz(array $attemptoptions) {
116 $quizobj = quiz_settings::create((int) $attemptoptions['quiz']->id);
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);
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);
128 // Process responses from the student.
129 $attemptobj = quiz_attempt::create($attempt->id);
130 $attemptobj->process_submitted_actions($timenow, false, $attemptoptions['tosubmit']);
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);
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.
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
152 list($passstudent, $failstudent) = $students;
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']]
162 $completioninfo = new \completion_info($cm->get_course());
163 $completiondetails = new cm_completion_details($completioninfo, $cm, (int) $passstudent->id);
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
172 // Do a failing attempt.
173 $this->do_attempt_quiz([
174 'quiz' => $quiz,
175 'student' => $failstudent,
176 'attemptnumber' => 1,
177 'tosubmit' => [1 => ['answer' => '0']]
180 $completiondetails = new cm_completion_details($completioninfo, $cm, (int) $failstudent->id);
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
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.
194 * @covers ::get_state
195 * @covers ::get_custom_rule_descriptions
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
209 list($passstudent, $exhauststudent) = $students;
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']]
219 $completioninfo = new \completion_info($cm->get_course());
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']
231 // Do a failing attempt.
232 $this->do_attempt_quiz([
233 'quiz' => $quiz,
234 'student' => $exhauststudent,
235 'attemptnumber' => 1,
236 'tosubmit' => [1 => ['answer' => '0']]
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']
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']]
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']
269 * Test checking the completion state of a quiz.
270 * To be completed, this quiz requires a minimum number of attempts.
272 * @covers ::get_state
273 * @covers ::get_custom_rule_descriptions
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
285 list($student) = $students;
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']]
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']
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']]
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']
323 * Test for get_defined_custom_rules().
325 * @covers ::get_defined_custom_rules
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
337 * Test update moduleinfo.
339 * @covers \update_moduleinfo
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,
356 $course = $cm->get_course();
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']]
366 $completioninfo = new \completion_info($course);
367 $cminfo = \cm_info::create($cm);
368 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id);
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
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);
381 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id);
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
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']]
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);
402 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id);
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
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);
415 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id);
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
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);
428 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id);
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
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);
441 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id);
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
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);
453 $completiondetails = new cm_completion_details($completioninfo, $cminfo, (int) $student->id);
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
464 * Set up moduleinfo object sample data for quiz instance.
466 * @param cm_info $cm course-module instance
467 * @param stdClass $quiz quiz instance data.
468 * @param stdClass $course Course related data.
469 * @param int $gradepass Grade to pass and completed completion.
470 * @param string $grademethod grade attempt method.
471 * @return stdClass
473 private function prepare_module_info(cm_info $cm, stdClass $quiz, stdClass $course,
474 int $gradepass, string $grademethod): \stdClass {
475 $grouping = $this->getDataGenerator()->create_grouping(['courseid' => $course->id]);
476 // Module test values.
477 $moduleinfo = new \stdClass();
478 $moduleinfo->coursemodule = $cm->id;
479 $moduleinfo->section = 1;
480 $moduleinfo->course = $course->id;
481 $moduleinfo->groupingid = $grouping->id;
482 $draftideditor = 0;
483 file_prepare_draft_area($draftideditor, null, null, null, null);
484 $moduleinfo->introeditor = ['text' => 'This is a module', 'format' => FORMAT_HTML, 'itemid' => $draftideditor];
485 $moduleinfo->modulename = 'quiz';
486 $moduleinfo->quizpassword = '';
487 $moduleinfo->cmidnumber = '';
488 $moduleinfo->maxmarksopen = 1;
489 $moduleinfo->marksopen = 1;
490 $moduleinfo->visible = 1;
491 $moduleinfo->visibleoncoursepage = 1;
492 $moduleinfo->completion = COMPLETION_TRACKING_AUTOMATIC;
493 $moduleinfo->completionview = COMPLETION_VIEW_NOT_REQUIRED;
494 $moduleinfo->name = $quiz->name;
495 $moduleinfo->timeopen = $quiz->timeopen;
496 $moduleinfo->timeclose = $quiz->timeclose;
497 $moduleinfo->timelimit = $quiz->timelimit;
498 $moduleinfo->graceperiod = $quiz->graceperiod;
499 $moduleinfo->decimalpoints = $quiz->decimalpoints;
500 $moduleinfo->questiondecimalpoints = $quiz->questiondecimalpoints;
501 $moduleinfo->gradepass = $gradepass;
502 $moduleinfo->grademethod = $grademethod;
504 return $moduleinfo;