MDL-77564 Quiz display options: Hide or show the grade information
[moodle.git] / mod / quiz / tests / external / external_test.php
blob9fc37410f38869ae10e8da5eba279ee5ee3d2e9e
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 /**
18 * Quiz module external functions tests.
20 * @package mod_quiz
21 * @category external
22 * @copyright 2016 Juan Leyva <juan@moodle.com>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 * @since Moodle 3.1
27 namespace mod_quiz\external;
29 use core_external\external_api;
30 use core_question\local\bank\question_version_status;
31 use externallib_advanced_testcase;
32 use mod_quiz\question\display_options;
33 use mod_quiz\quiz_attempt;
34 use mod_quiz\quiz_settings;
35 use mod_quiz\structure;
36 use mod_quiz_external;
37 use moodle_exception;
39 defined('MOODLE_INTERNAL') || die();
41 global $CFG;
43 require_once($CFG->dirroot . '/webservice/tests/helpers.php');
45 /**
46 * Silly class to access mod_quiz_external internal methods.
48 * @package mod_quiz
49 * @copyright 2016 Juan Leyva <juan@moodle.com>
50 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
51 * @since Moodle 3.1
53 class testable_mod_quiz_external extends mod_quiz_external {
55 /**
56 * Public accessor.
58 * @param array $params Array of parameters including the attemptid and preflight data
59 * @param bool $checkaccessrules whether to check the quiz access rules or not
60 * @param bool $failifoverdue whether to return error if the attempt is overdue
61 * @return array containing the attempt object and access messages
63 public static function validate_attempt($params, $checkaccessrules = true, $failifoverdue = true) {
64 return parent::validate_attempt($params, $checkaccessrules, $failifoverdue);
67 /**
68 * Public accessor.
70 * @param array $params Array of parameters including the attemptid
71 * @return array containing the attempt object and display options
73 public static function validate_attempt_review($params) {
74 return parent::validate_attempt_review($params);
78 /**
79 * Quiz module external functions tests
81 * @package mod_quiz
82 * @category external
83 * @copyright 2016 Juan Leyva <juan@moodle.com>
84 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
85 * @since Moodle 3.1
87 class external_test extends externallib_advanced_testcase {
89 /** @var \stdClass course record. */
90 protected $course;
92 /** @var \stdClass activity record. */
93 protected $quiz;
95 /** @var \context_module context instance. */
96 protected $context;
98 /** @var \stdClass */
99 protected $cm;
101 /** @var \stdClass user record. */
102 protected $student;
104 /** @var \stdClass user record. */
105 protected $teacher;
107 /** @var \stdClass user role record. */
108 protected $studentrole;
110 /** @var \stdClass user role record. */
111 protected $teacherrole;
114 * Set up for every test
116 public function setUp(): void {
117 global $DB;
118 $this->resetAfterTest();
119 $this->setAdminUser();
121 // Setup test data.
122 $this->course = $this->getDataGenerator()->create_course();
123 $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]);
124 $this->context = \context_module::instance($this->quiz->cmid);
125 $this->cm = get_coursemodule_from_instance('quiz', $this->quiz->id);
127 // Create users.
128 $this->student = self::getDataGenerator()->create_user();
129 $this->teacher = self::getDataGenerator()->create_user();
131 // Users enrolments.
132 $this->studentrole = $DB->get_record('role', ['shortname' => 'student']);
133 $this->teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
134 // Allow student to receive messages.
135 $coursecontext = \context_course::instance($this->course->id);
136 assign_capability('mod/quiz:emailnotifysubmission', CAP_ALLOW, $this->teacherrole->id, $coursecontext, true);
138 $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id, 'manual');
139 $this->getDataGenerator()->enrol_user($this->teacher->id, $this->course->id, $this->teacherrole->id, 'manual');
143 * Create a quiz with questions including a started or finished attempt optionally
145 * @param boolean $startattempt whether to start a new attempt
146 * @param boolean $finishattempt whether to finish the new attempt
147 * @param string $behaviour the quiz preferredbehaviour, defaults to 'deferredfeedback'.
148 * @param boolean $includeqattachments whether to include a question that supports attachments, defaults to false.
149 * @param array $extraoptions extra options for Quiz.
150 * @return array array containing the quiz, context and the attempt
152 private function create_quiz_with_questions($startattempt = false, $finishattempt = false, $behaviour = 'deferredfeedback',
153 $includeqattachments = false, $extraoptions = []) {
155 // Create a new quiz with attempts.
156 $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
157 $data = ['course' => $this->course->id,
158 'sumgrades' => 2,
159 'preferredbehaviour' => $behaviour];
160 $data = array_merge($data, $extraoptions);
161 $quiz = $quizgenerator->create_instance($data);
162 $context = \context_module::instance($quiz->cmid);
164 // Create a couple of questions.
165 $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
167 $cat = $questiongenerator->create_question_category();
168 $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
169 quiz_add_quiz_question($question->id, $quiz);
170 $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
171 quiz_add_quiz_question($question->id, $quiz);
173 if ($includeqattachments) {
174 $question = $questiongenerator->create_question('essay', null, ['category' => $cat->id, 'attachments' => 1,
175 'attachmentsrequired' => 1]);
176 quiz_add_quiz_question($question->id, $quiz);
179 $quizobj = quiz_settings::create($quiz->id, $this->student->id);
181 // Set grade to pass.
182 $item = \grade_item::fetch(['courseid' => $this->course->id, 'itemtype' => 'mod',
183 'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null]);
184 $item->gradepass = 80;
185 $item->update();
187 if ($startattempt or $finishattempt) {
188 // Now, do one attempt.
189 $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
190 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
192 $timenow = time();
193 $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id);
194 quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
195 quiz_attempt_save_started($quizobj, $quba, $attempt);
196 $attemptobj = quiz_attempt::create($attempt->id);
198 if ($finishattempt) {
199 // Process some responses from the student.
200 $tosubmit = [1 => ['answer' => '3.14']];
201 $attemptobj->process_submitted_actions(time(), false, $tosubmit);
203 // Finish the attempt.
204 $attemptobj->process_finish(time(), false);
206 return [$quiz, $context, $quizobj, $attempt, $attemptobj, $quba];
207 } else {
208 return [$quiz, $context, $quizobj];
214 * Test get quizzes by courses
216 public function test_mod_quiz_get_quizzes_by_courses() {
217 global $DB;
219 // Create additional course.
220 $course2 = self::getDataGenerator()->create_course();
222 // Second quiz.
223 $record = new \stdClass();
224 $record->course = $course2->id;
225 $record->intro = '<button>Test with HTML allowed.</button>';
226 $quiz2 = self::getDataGenerator()->create_module('quiz', $record);
228 // Execute real Moodle enrolment as we'll call unenrol() method on the instance later.
229 $enrol = enrol_get_plugin('manual');
230 $enrolinstances = enrol_get_instances($course2->id, true);
231 foreach ($enrolinstances as $courseenrolinstance) {
232 if ($courseenrolinstance->enrol == "manual") {
233 $instance2 = $courseenrolinstance;
234 break;
237 $enrol->enrol_user($instance2, $this->student->id, $this->studentrole->id);
239 self::setUser($this->student);
241 $returndescription = mod_quiz_external::get_quizzes_by_courses_returns();
243 // Create what we expect to be returned when querying the two courses.
244 // First for the student user.
245 $allusersfields = ['id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'lang',
246 'timeopen', 'timeclose', 'grademethod', 'section', 'visible', 'groupmode', 'groupingid',
247 'attempts', 'timelimit', 'grademethod', 'decimalpoints', 'questiondecimalpoints', 'sumgrades',
248 'grade', 'preferredbehaviour', 'hasfeedback'];
249 $userswithaccessfields = ['attemptonlast', 'reviewattempt', 'reviewcorrectness', 'reviewmaxmarks', 'reviewmarks',
250 'reviewspecificfeedback', 'reviewgeneralfeedback', 'reviewrightanswer',
251 'reviewoverallfeedback', 'questionsperpage', 'navmethod',
252 'browsersecurity', 'delay1', 'delay2', 'showuserpicture', 'showblocks',
253 'completionattemptsexhausted', 'completionpass', 'autosaveperiod', 'hasquestions',
254 'overduehandling', 'graceperiod', 'canredoquestions', 'allowofflineattempts'];
255 $managerfields = ['shuffleanswers', 'timecreated', 'timemodified', 'password', 'subnet'];
257 // Add expected coursemodule and other data.
258 $quiz1 = $this->quiz;
259 $quiz1->coursemodule = $quiz1->cmid;
260 $quiz1->introformat = 1;
261 $quiz1->section = 0;
262 $quiz1->visible = true;
263 $quiz1->groupmode = 0;
264 $quiz1->groupingid = 0;
265 $quiz1->hasquestions = 0;
266 $quiz1->hasfeedback = 0;
267 $quiz1->completionpass = 0;
268 $quiz1->autosaveperiod = get_config('quiz', 'autosaveperiod');
269 $quiz1->introfiles = [];
270 $quiz1->lang = '';
272 $quiz2->coursemodule = $quiz2->cmid;
273 $quiz2->introformat = 1;
274 $quiz2->section = 0;
275 $quiz2->visible = true;
276 $quiz2->groupmode = 0;
277 $quiz2->groupingid = 0;
278 $quiz2->hasquestions = 0;
279 $quiz2->hasfeedback = 0;
280 $quiz2->completionpass = 0;
281 $quiz2->autosaveperiod = get_config('quiz', 'autosaveperiod');
282 $quiz2->introfiles = [];
283 $quiz2->lang = '';
285 foreach (array_merge($allusersfields, $userswithaccessfields) as $field) {
286 $expected1[$field] = $quiz1->{$field};
287 $expected2[$field] = $quiz2->{$field};
290 $expectedquizzes = [$expected2, $expected1];
292 // Call the external function passing course ids.
293 $result = mod_quiz_external::get_quizzes_by_courses([$course2->id, $this->course->id]);
294 $result = external_api::clean_returnvalue($returndescription, $result);
296 $this->assertEquals($expectedquizzes, $result['quizzes']);
297 $this->assertCount(0, $result['warnings']);
299 // Call the external function without passing course id.
300 $result = mod_quiz_external::get_quizzes_by_courses();
301 $result = external_api::clean_returnvalue($returndescription, $result);
302 $this->assertEquals($expectedquizzes, $result['quizzes']);
303 $this->assertCount(0, $result['warnings']);
305 // Unenrol user from second course and alter expected quizzes.
306 $enrol->unenrol_user($instance2, $this->student->id);
307 array_shift($expectedquizzes);
309 // Call the external function without passing course id.
310 $result = mod_quiz_external::get_quizzes_by_courses();
311 $result = external_api::clean_returnvalue($returndescription, $result);
312 $this->assertEquals($expectedquizzes, $result['quizzes']);
314 // Call for the second course we unenrolled the user from, expected warning.
315 $result = mod_quiz_external::get_quizzes_by_courses([$course2->id]);
316 $this->assertCount(1, $result['warnings']);
317 $this->assertEquals('1', $result['warnings'][0]['warningcode']);
318 $this->assertEquals($course2->id, $result['warnings'][0]['itemid']);
320 // Now, try as a teacher for getting all the additional fields.
321 self::setUser($this->teacher);
323 foreach ($managerfields as $field) {
324 $expectedquizzes[0][$field] = $quiz1->{$field};
327 $result = mod_quiz_external::get_quizzes_by_courses();
328 $result = external_api::clean_returnvalue($returndescription, $result);
329 $this->assertEquals($expectedquizzes, $result['quizzes']);
331 // Admin also should get all the information.
332 self::setAdminUser();
334 $result = mod_quiz_external::get_quizzes_by_courses([$this->course->id]);
335 $result = external_api::clean_returnvalue($returndescription, $result);
336 $this->assertEquals($expectedquizzes, $result['quizzes']);
338 // Now, prevent access.
339 $enrol->enrol_user($instance2, $this->student->id);
341 self::setUser($this->student);
343 $quiz2->timeclose = time() - DAYSECS;
344 $DB->update_record('quiz', $quiz2);
346 $result = mod_quiz_external::get_quizzes_by_courses();
347 $result = external_api::clean_returnvalue($returndescription, $result);
348 $this->assertCount(2, $result['quizzes']);
349 // We only see a limited set of fields.
350 $this->assertCount(5, $result['quizzes'][0]);
351 $this->assertEquals($quiz2->id, $result['quizzes'][0]['id']);
352 $this->assertEquals($quiz2->cmid, $result['quizzes'][0]['coursemodule']);
353 $this->assertEquals($quiz2->course, $result['quizzes'][0]['course']);
354 $this->assertEquals($quiz2->name, $result['quizzes'][0]['name']);
355 $this->assertEquals($quiz2->course, $result['quizzes'][0]['course']);
357 $this->assertFalse(isset($result['quizzes'][0]['timelimit']));
362 * Test test_view_quiz
364 public function test_view_quiz() {
365 global $DB;
367 // Test invalid instance id.
368 try {
369 mod_quiz_external::view_quiz(0);
370 $this->fail('Exception expected due to invalid mod_quiz instance id.');
371 } catch (moodle_exception $e) {
372 $this->assertEquals('invalidrecord', $e->errorcode);
375 // Test not-enrolled user.
376 $usernotenrolled = self::getDataGenerator()->create_user();
377 $this->setUser($usernotenrolled);
378 try {
379 mod_quiz_external::view_quiz($this->quiz->id);
380 $this->fail('Exception expected due to not enrolled user.');
381 } catch (moodle_exception $e) {
382 $this->assertEquals('requireloginerror', $e->errorcode);
385 // Test user with full capabilities.
386 $this->setUser($this->student);
388 // Trigger and capture the event.
389 $sink = $this->redirectEvents();
391 $result = mod_quiz_external::view_quiz($this->quiz->id);
392 $result = external_api::clean_returnvalue(mod_quiz_external::view_quiz_returns(), $result);
393 $this->assertTrue($result['status']);
395 $events = $sink->get_events();
396 $this->assertCount(1, $events);
397 $event = array_shift($events);
399 // Checking that the event contains the expected values.
400 $this->assertInstanceOf('\mod_quiz\event\course_module_viewed', $event);
401 $this->assertEquals($this->context, $event->get_context());
402 $moodlequiz = new \moodle_url('/mod/quiz/view.php', ['id' => $this->cm->id]);
403 $this->assertEquals($moodlequiz, $event->get_url());
404 $this->assertEventContextNotUsed($event);
405 $this->assertNotEmpty($event->get_name());
407 // Test user with no capabilities.
408 // We need a explicit prohibit since this capability is only defined in authenticated user and guest roles.
409 assign_capability('mod/quiz:view', CAP_PROHIBIT, $this->studentrole->id, $this->context->id);
410 // Empty all the caches that may be affected by this change.
411 accesslib_clear_all_caches_for_unit_testing();
412 \course_modinfo::clear_instance_cache();
414 try {
415 mod_quiz_external::view_quiz($this->quiz->id);
416 $this->fail('Exception expected due to missing capability.');
417 } catch (moodle_exception $e) {
418 $this->assertEquals('requireloginerror', $e->errorcode);
424 * Test get_user_attempts
426 public function test_get_user_attempts() {
428 // Create a quiz with one attempt finished.
429 list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true, true);
431 $this->setUser($this->student);
432 $result = mod_quiz_external::get_user_attempts($quiz->id);
433 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
435 $this->assertCount(1, $result['attempts']);
436 $this->assertEquals($attempt->id, $result['attempts'][0]['id']);
437 $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']);
438 $this->assertEquals($this->student->id, $result['attempts'][0]['userid']);
439 $this->assertEquals(1, $result['attempts'][0]['attempt']);
440 $this->assertArrayHasKey('sumgrades', $result['attempts'][0]);
441 $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']);
443 // Test filters. Only finished.
444 $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'finished', false);
445 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
447 $this->assertCount(1, $result['attempts']);
448 $this->assertEquals($attempt->id, $result['attempts'][0]['id']);
450 // Test filters. All attempts.
451 $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'all', false);
452 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
454 $this->assertCount(1, $result['attempts']);
455 $this->assertEquals($attempt->id, $result['attempts'][0]['id']);
457 // Test filters. Unfinished.
458 $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'unfinished', false);
459 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
461 $this->assertCount(0, $result['attempts']);
463 // Start a new attempt, but not finish it.
464 $timenow = time();
465 $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id);
466 $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
467 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
469 quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
470 quiz_attempt_save_started($quizobj, $quba, $attempt);
472 // Test filters. All attempts.
473 $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'all', false);
474 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
476 $this->assertCount(2, $result['attempts']);
478 // Test filters. Unfinished.
479 $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'unfinished', false);
480 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
482 $this->assertCount(1, $result['attempts']);
484 // Test manager can see user attempts.
485 $this->setUser($this->teacher);
486 $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id);
487 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
489 $this->assertCount(1, $result['attempts']);
490 $this->assertEquals($this->student->id, $result['attempts'][0]['userid']);
492 $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'all');
493 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
495 $this->assertCount(2, $result['attempts']);
496 $this->assertEquals($this->student->id, $result['attempts'][0]['userid']);
498 // Invalid parameters.
499 try {
500 mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'INVALID_PARAMETER');
501 $this->fail('Exception expected due to missing capability.');
502 } catch (\invalid_parameter_exception $e) {
503 $this->assertEquals('invalidparameter', $e->errorcode);
508 * Test get_user_attempts with marks hidden
510 public function test_get_user_attempts_with_marks_hidden() {
511 // Create quiz with one attempt finished and hide the mark.
512 list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(
513 true, true, 'deferredfeedback', false,
514 ['marksduring' => 0, 'marksimmediately' => 0, 'marksopen' => 0, 'marksclosed' => 0]);
516 // Student cannot see the grades.
517 $this->setUser($this->student);
518 $result = mod_quiz_external::get_user_attempts($quiz->id);
519 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
521 $this->assertCount(1, $result['attempts']);
522 $this->assertEquals($attempt->id, $result['attempts'][0]['id']);
523 $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']);
524 $this->assertEquals($this->student->id, $result['attempts'][0]['userid']);
525 $this->assertEquals(1, $result['attempts'][0]['attempt']);
526 $this->assertArrayHasKey('sumgrades', $result['attempts'][0]);
527 $this->assertEquals(null, $result['attempts'][0]['sumgrades']);
529 // Test manager can see user grades.
530 $this->setUser($this->teacher);
531 $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id);
532 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result);
534 $this->assertCount(1, $result['attempts']);
535 $this->assertEquals($attempt->id, $result['attempts'][0]['id']);
536 $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']);
537 $this->assertEquals($this->student->id, $result['attempts'][0]['userid']);
538 $this->assertEquals(1, $result['attempts'][0]['attempt']);
539 $this->assertArrayHasKey('sumgrades', $result['attempts'][0]);
540 $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']);
544 * Test get_user_best_grade
546 public function test_get_user_best_grade() {
547 $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
548 $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
549 $questioncat = $questiongenerator->create_question_category();
551 // Create a new quiz.
552 $quizapi1 = $quizgenerator->create_instance([
553 'name' => 'Test Quiz API 1',
554 'course' => $this->course->id,
555 'sumgrades' => 1
557 $quizapi2 = $quizgenerator->create_instance([
558 'name' => 'Test Quiz API 2',
559 'course' => $this->course->id,
560 'sumgrades' => 1,
561 'marksduring' => 0,
562 'marksimmediately' => 0,
563 'marksopen' => 0,
564 'marksclosed' => 0
567 // Create a question.
568 $question = $questiongenerator->create_question('numerical', null, ['category' => $questioncat->id]);
570 // Add question to the quizzes.
571 quiz_add_quiz_question($question->id, $quizapi1);
572 quiz_add_quiz_question($question->id, $quizapi2);
574 // Create quiz object.
575 $quizapiobj1 = quiz_settings::create($quizapi1->id, $this->student->id);
576 $quizapiobj2 = quiz_settings::create($quizapi2->id, $this->student->id);
578 // Set grade to pass.
579 $item = \grade_item::fetch([
580 'courseid' => $this->course->id,
581 'itemtype' => 'mod',
582 'itemmodule' => 'quiz',
583 'iteminstance' => $quizapi1->id,
584 'outcomeid' => null
586 $item->gradepass = 80;
587 $item->update();
589 $item = \grade_item::fetch([
590 'courseid' => $this->course->id,
591 'itemtype' => 'mod',
592 'itemmodule' => 'quiz',
593 'iteminstance' => $quizapi2->id,
594 'outcomeid' => null
596 $item->gradepass = 80;
597 $item->update();
599 // Start the passing attempt.
600 $quba1 = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizapiobj1->get_context());
601 $quba1->set_preferred_behaviour($quizapiobj1->get_quiz()->preferredbehaviour);
603 $quba2 = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizapiobj2->get_context());
604 $quba2->set_preferred_behaviour($quizapiobj2->get_quiz()->preferredbehaviour);
606 // Start the testing for quizapi1 that allow the student to view the grade.
608 $this->setUser($this->student);
609 $result = mod_quiz_external::get_user_best_grade($quizapi1->id);
610 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
612 // No grades yet.
613 $this->assertFalse($result['hasgrade']);
614 $this->assertTrue(!isset($result['grade']));
616 // Start the attempt.
617 $timenow = time();
618 $attempt = quiz_create_attempt($quizapiobj1, 1, false, $timenow, false, $this->student->id);
619 quiz_start_new_attempt($quizapiobj1, $quba1, $attempt, 1, $timenow);
620 quiz_attempt_save_started($quizapiobj1, $quba1, $attempt);
622 // Process some responses from the student.
623 $attemptobj = quiz_attempt::create($attempt->id);
624 $attemptobj->process_submitted_actions($timenow, false, [1 => ['answer' => '3.14']]);
626 // Finish the attempt.
627 $attemptobj->process_finish($timenow, false);
629 $result = mod_quiz_external::get_user_best_grade($quizapi1->id);
630 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
632 // Now I have grades.
633 $this->assertTrue($result['hasgrade']);
634 $this->assertEquals(100.0, $result['grade']);
635 $this->assertEquals(80, $result['gradetopass']);
637 // We should not see other users grades.
638 $anotherstudent = self::getDataGenerator()->create_user();
639 $this->getDataGenerator()->enrol_user($anotherstudent->id, $this->course->id, $this->studentrole->id, 'manual');
641 try {
642 mod_quiz_external::get_user_best_grade($quizapi1->id, $anotherstudent->id);
643 $this->fail('Exception expected due to missing capability.');
644 } catch (\required_capability_exception $e) {
645 $this->assertEquals('nopermissions', $e->errorcode);
648 // Teacher must be able to see student grades.
649 $this->setUser($this->teacher);
651 $result = mod_quiz_external::get_user_best_grade($quizapi1->id, $this->student->id);
652 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
654 $this->assertTrue($result['hasgrade']);
655 $this->assertEquals(100.0, $result['grade']);
656 $this->assertEquals(80, $result['gradetopass']);
658 // Invalid user.
659 try {
660 mod_quiz_external::get_user_best_grade($this->quiz->id, -1);
661 $this->fail('Exception expected due to missing capability.');
662 } catch (\dml_missing_record_exception $e) {
663 $this->assertEquals('invaliduser', $e->errorcode);
666 // End the testing for quizapi1 that allow the student to view the grade.
668 // Start the testing for quizapi2 that do not allow the student to view the grade.
670 $this->setUser($this->student);
671 $result = mod_quiz_external::get_user_best_grade($quizapi2->id);
672 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
674 // No grades yet.
675 $this->assertFalse($result['hasgrade']);
676 $this->assertTrue(!isset($result['grade']));
678 // Start the attempt.
679 $timenow = time();
680 $attempt = quiz_create_attempt($quizapiobj2, 1, false, $timenow, false, $this->student->id);
681 quiz_start_new_attempt($quizapiobj2, $quba2, $attempt, 1, $timenow);
682 quiz_attempt_save_started($quizapiobj2, $quba2, $attempt);
684 // Process some responses from the student.
685 $attemptobj = quiz_attempt::create($attempt->id);
686 $attemptobj->process_submitted_actions($timenow, false, [1 => ['answer' => '3.14']]);
688 // Finish the attempt.
689 $attemptobj->process_finish($timenow, false);
691 $result = mod_quiz_external::get_user_best_grade($quizapi2->id);
692 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
694 // Now I have grades but I will not be allowed to see it.
695 $this->assertFalse($result['hasgrade']);
696 $this->assertTrue(!isset($result['grade']));
698 // Teacher must be able to see student grades.
699 $this->setUser($this->teacher);
701 $result = mod_quiz_external::get_user_best_grade($quizapi2->id, $this->student->id);
702 $result = external_api::clean_returnvalue(mod_quiz_external::get_user_best_grade_returns(), $result);
704 $this->assertTrue($result['hasgrade']);
705 $this->assertEquals(100.0, $result['grade']);
707 // End the testing for quizapi2 that do not allow the student to view the grade.
711 * Test get_combined_review_options.
712 * This is a basic test, this is already tested in display_options_testcase.
714 public function test_get_combined_review_options() {
715 global $DB;
717 // Create a new quiz with attempts.
718 $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
719 $data = ['course' => $this->course->id,
720 'sumgrades' => 1];
721 $quiz = $quizgenerator->create_instance($data);
723 // Create a couple of questions.
724 $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
726 $cat = $questiongenerator->create_question_category();
727 $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
728 quiz_add_quiz_question($question->id, $quiz);
730 $quizobj = quiz_settings::create($quiz->id, $this->student->id);
732 // Set grade to pass.
733 $item = \grade_item::fetch(['courseid' => $this->course->id, 'itemtype' => 'mod',
734 'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null]);
735 $item->gradepass = 80;
736 $item->update();
738 // Start the passing attempt.
739 $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
740 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
742 $timenow = time();
743 $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id);
744 quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
745 quiz_attempt_save_started($quizobj, $quba, $attempt);
747 $this->setUser($this->student);
749 $result = mod_quiz_external::get_combined_review_options($quiz->id);
750 $result = external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result);
752 // Expected values.
753 $expected = [
754 "someoptions" => [
755 ["name" => "feedback", "value" => 1],
756 ["name" => "generalfeedback", "value" => 1],
757 ["name" => "rightanswer", "value" => 1],
758 ["name" => "overallfeedback", "value" => 0],
759 ["name" => "marks", "value" => 2],
761 "alloptions" => [
762 ["name" => "feedback", "value" => 1],
763 ["name" => "generalfeedback", "value" => 1],
764 ["name" => "rightanswer", "value" => 1],
765 ["name" => "overallfeedback", "value" => 0],
766 ["name" => "marks", "value" => 2],
768 "warnings" => [],
771 $this->assertEquals($expected, $result);
773 // Now, finish the attempt.
774 $attemptobj = quiz_attempt::create($attempt->id);
775 $attemptobj->process_finish($timenow, false);
777 $expected = [
778 "someoptions" => [
779 ["name" => "feedback", "value" => 1],
780 ["name" => "generalfeedback", "value" => 1],
781 ["name" => "rightanswer", "value" => 1],
782 ["name" => "overallfeedback", "value" => 1],
783 ["name" => "marks", "value" => 2],
785 "alloptions" => [
786 ["name" => "feedback", "value" => 1],
787 ["name" => "generalfeedback", "value" => 1],
788 ["name" => "rightanswer", "value" => 1],
789 ["name" => "overallfeedback", "value" => 1],
790 ["name" => "marks", "value" => 2],
792 "warnings" => [],
795 // We should see now the overall feedback.
796 $result = mod_quiz_external::get_combined_review_options($quiz->id);
797 $result = external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result);
798 $this->assertEquals($expected, $result);
800 // Start a new attempt, but not finish it.
801 $timenow = time();
802 $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id);
803 $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
804 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
805 quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
806 quiz_attempt_save_started($quizobj, $quba, $attempt);
808 $expected = [
809 "someoptions" => [
810 ["name" => "feedback", "value" => 1],
811 ["name" => "generalfeedback", "value" => 1],
812 ["name" => "rightanswer", "value" => 1],
813 ["name" => "overallfeedback", "value" => 1],
814 ["name" => "marks", "value" => 2],
816 "alloptions" => [
817 ["name" => "feedback", "value" => 1],
818 ["name" => "generalfeedback", "value" => 1],
819 ["name" => "rightanswer", "value" => 1],
820 ["name" => "overallfeedback", "value" => 0],
821 ["name" => "marks", "value" => 2],
823 "warnings" => [],
826 $result = mod_quiz_external::get_combined_review_options($quiz->id);
827 $result = external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result);
828 $this->assertEquals($expected, $result);
830 // Teacher, for see student options.
831 $this->setUser($this->teacher);
833 $result = mod_quiz_external::get_combined_review_options($quiz->id, $this->student->id);
834 $result = external_api::clean_returnvalue(mod_quiz_external::get_combined_review_options_returns(), $result);
836 $this->assertEquals($expected, $result);
838 // Invalid user.
839 try {
840 mod_quiz_external::get_combined_review_options($quiz->id, -1);
841 $this->fail('Exception expected due to missing capability.');
842 } catch (\dml_missing_record_exception $e) {
843 $this->assertEquals('invaliduser', $e->errorcode);
848 * Test start_attempt
850 public function test_start_attempt() {
851 global $DB;
853 // Create a new quiz with questions.
854 list($quiz, $context, $quizobj) = $this->create_quiz_with_questions();
856 $this->setUser($this->student);
858 // Try to open attempt in closed quiz.
859 $quiz->timeopen = time() - WEEKSECS;
860 $quiz->timeclose = time() - DAYSECS;
861 $DB->update_record('quiz', $quiz);
862 $result = mod_quiz_external::start_attempt($quiz->id);
863 $result = external_api::clean_returnvalue(mod_quiz_external::start_attempt_returns(), $result);
865 $this->assertEquals([], $result['attempt']);
866 $this->assertCount(1, $result['warnings']);
868 // Now with a password.
869 $quiz->timeopen = 0;
870 $quiz->timeclose = 0;
871 $quiz->password = 'abc';
872 $DB->update_record('quiz', $quiz);
874 try {
875 mod_quiz_external::start_attempt($quiz->id, [["name" => "quizpassword", "value" => 'bad']]);
876 $this->fail('Exception expected due to invalid passwod.');
877 } catch (moodle_exception $e) {
878 $this->assertEquals(get_string('passworderror', 'quizaccess_password'), $e->errorcode);
881 // Now, try everything correct.
882 $result = mod_quiz_external::start_attempt($quiz->id, [["name" => "quizpassword", "value" => 'abc']]);
883 $result = external_api::clean_returnvalue(mod_quiz_external::start_attempt_returns(), $result);
885 $this->assertEquals(1, $result['attempt']['attempt']);
886 $this->assertEquals($this->student->id, $result['attempt']['userid']);
887 $this->assertEquals($quiz->id, $result['attempt']['quiz']);
888 $this->assertCount(0, $result['warnings']);
889 $attemptid = $result['attempt']['id'];
891 // We are good, try to start a new attempt now.
893 try {
894 mod_quiz_external::start_attempt($quiz->id, [["name" => "quizpassword", "value" => 'abc']]);
895 $this->fail('Exception expected due to attempt not finished.');
896 } catch (moodle_exception $e) {
897 $this->assertEquals('attemptstillinprogress', $e->errorcode);
900 // Finish the started attempt.
902 // Process some responses from the student.
903 $timenow = time();
904 $attemptobj = quiz_attempt::create($attemptid);
905 $tosubmit = [1 => ['answer' => '3.14']];
906 $attemptobj->process_submitted_actions($timenow, false, $tosubmit);
908 // Finish the attempt.
909 $attemptobj = quiz_attempt::create($attemptid);
910 $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question());
911 $attemptobj->process_finish($timenow, false);
913 // We should be able to start a new attempt.
914 $result = mod_quiz_external::start_attempt($quiz->id, [["name" => "quizpassword", "value" => 'abc']]);
915 $result = external_api::clean_returnvalue(mod_quiz_external::start_attempt_returns(), $result);
917 $this->assertEquals(2, $result['attempt']['attempt']);
918 $this->assertEquals($this->student->id, $result['attempt']['userid']);
919 $this->assertEquals($quiz->id, $result['attempt']['quiz']);
920 $this->assertCount(0, $result['warnings']);
922 // Test user with no capabilities.
923 // We need a explicit prohibit since this capability is only defined in authenticated user and guest roles.
924 assign_capability('mod/quiz:attempt', CAP_PROHIBIT, $this->studentrole->id, $context->id);
925 // Empty all the caches that may be affected by this change.
926 accesslib_clear_all_caches_for_unit_testing();
927 \course_modinfo::clear_instance_cache();
929 try {
930 mod_quiz_external::start_attempt($quiz->id);
931 $this->fail('Exception expected due to missing capability.');
932 } catch (\required_capability_exception $e) {
933 $this->assertEquals('nopermissions', $e->errorcode);
939 * Test validate_attempt
941 public function test_validate_attempt() {
942 global $DB;
944 // Create a new quiz with one attempt started.
945 list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true);
947 $this->setUser($this->student);
949 // Invalid attempt.
950 try {
951 $params = ['attemptid' => -1, 'page' => 0];
952 testable_mod_quiz_external::validate_attempt($params);
953 $this->fail('Exception expected due to invalid attempt id.');
954 } catch (\dml_missing_record_exception $e) {
955 $this->assertEquals('invalidrecord', $e->errorcode);
958 // Test OK case.
959 $params = ['attemptid' => $attempt->id, 'page' => 0];
960 $result = testable_mod_quiz_external::validate_attempt($params);
961 $this->assertEquals($attempt->id, $result[0]->get_attempt()->id);
962 $this->assertEquals([], $result[1]);
964 // Test with preflight data.
965 $quiz->password = 'abc';
966 $DB->update_record('quiz', $quiz);
968 try {
969 $params = ['attemptid' => $attempt->id, 'page' => 0,
970 'preflightdata' => [["name" => "quizpassword", "value" => 'bad']]];
971 testable_mod_quiz_external::validate_attempt($params);
972 $this->fail('Exception expected due to invalid passwod.');
973 } catch (moodle_exception $e) {
974 $this->assertEquals(get_string('passworderror', 'quizaccess_password'), $e->errorcode);
977 // Now, try everything correct.
978 $params['preflightdata'][0]['value'] = 'abc';
979 $result = testable_mod_quiz_external::validate_attempt($params);
980 $this->assertEquals($attempt->id, $result[0]->get_attempt()->id);
981 $this->assertEquals([], $result[1]);
983 // Page out of range.
984 $DB->update_record('quiz', $quiz);
985 $params['page'] = 4;
986 try {
987 testable_mod_quiz_external::validate_attempt($params);
988 $this->fail('Exception expected due to page out of range.');
989 } catch (moodle_exception $e) {
990 $this->assertEquals('Invalid page number', $e->errorcode);
993 $params['page'] = 0;
994 // Try to open attempt in closed quiz.
995 $quiz->timeopen = time() - WEEKSECS;
996 $quiz->timeclose = time() - DAYSECS;
997 $DB->update_record('quiz', $quiz);
999 // This should work, ommit access rules.
1000 testable_mod_quiz_external::validate_attempt($params, false);
1002 // Get a generic error because prior to checking the dates the attempt is closed.
1003 try {
1004 testable_mod_quiz_external::validate_attempt($params);
1005 $this->fail('Exception expected due to passed dates.');
1006 } catch (moodle_exception $e) {
1007 $this->assertEquals('attempterror', $e->errorcode);
1010 // Finish the attempt.
1011 $attemptobj = quiz_attempt::create($attempt->id);
1012 $attemptobj->process_finish(time(), false);
1014 try {
1015 testable_mod_quiz_external::validate_attempt($params, false);
1016 $this->fail('Exception expected due to attempt finished.');
1017 } catch (moodle_exception $e) {
1018 $this->assertEquals('attemptalreadyclosed', $e->errorcode);
1021 // Test user with no capabilities.
1022 // We need a explicit prohibit since this capability is only defined in authenticated user and guest roles.
1023 assign_capability('mod/quiz:attempt', CAP_PROHIBIT, $this->studentrole->id, $context->id);
1024 // Empty all the caches that may be affected by this change.
1025 accesslib_clear_all_caches_for_unit_testing();
1026 \course_modinfo::clear_instance_cache();
1028 try {
1029 testable_mod_quiz_external::validate_attempt($params);
1030 $this->fail('Exception expected due to missing permissions.');
1031 } catch (\required_capability_exception $e) {
1032 $this->assertEquals('nopermissions', $e->errorcode);
1035 // Now try with a different user.
1036 $this->setUser($this->teacher);
1038 $params['page'] = 0;
1039 try {
1040 testable_mod_quiz_external::validate_attempt($params);
1041 $this->fail('Exception expected due to not your attempt.');
1042 } catch (moodle_exception $e) {
1043 $this->assertEquals('notyourattempt', $e->errorcode);
1048 * Test get_attempt_data
1050 public function test_get_attempt_data() {
1051 global $DB;
1053 $timenow = time();
1054 // Create a new quiz with one attempt started.
1055 [$quiz, , $quizobj, $attempt] = $this->create_quiz_with_questions(true);
1056 /** @var structure $structure */
1057 $structure = $quizobj->get_structure();
1058 $structure->update_slot_display_number($structure->get_slot_id_for_slot(1), '1.a');
1060 // Set correctness mask so questions state can be fetched only after finishing the attempt.
1061 $DB->set_field('quiz', 'reviewcorrectness', display_options::IMMEDIATELY_AFTER, ['id' => $quiz->id]);
1063 // Having changed some settings, recreate the objects.
1064 $attemptobj = quiz_attempt::create($attempt->id);
1065 $quizobj = $attemptobj->get_quizobj();
1066 $quizobj->preload_questions();
1067 $quizobj->load_questions();
1068 $questions = $quizobj->get_questions();
1070 $this->setUser($this->student);
1072 // We receive one question per page.
1073 $result = mod_quiz_external::get_attempt_data($attempt->id, 0);
1074 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
1076 $this->assertEquals($attempt, (object) $result['attempt']);
1077 $this->assertEquals(1, $result['nextpage']);
1078 $this->assertCount(0, $result['messages']);
1079 $this->assertCount(1, $result['questions']);
1080 $this->assertEquals(1, $result['questions'][0]['slot']);
1081 $this->assertArrayNotHasKey('number', $result['questions'][0]);
1082 $this->assertEquals('1.a', $result['questions'][0]['questionnumber']);
1083 $this->assertEquals('numerical', $result['questions'][0]['type']);
1084 $this->assertArrayNotHasKey('state', $result['questions'][0]); // We don't receive the state yet.
1085 $this->assertEquals(get_string('notyetanswered', 'question'), $result['questions'][0]['status']);
1086 $this->assertFalse($result['questions'][0]['flagged']);
1087 $this->assertEquals(0, $result['questions'][0]['page']);
1088 $this->assertEmpty($result['questions'][0]['mark']);
1089 $this->assertEquals(1, $result['questions'][0]['maxmark']);
1090 $this->assertEquals(1, $result['questions'][0]['sequencecheck']);
1091 $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1092 $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1094 // Now try the last page.
1095 $result = mod_quiz_external::get_attempt_data($attempt->id, 1);
1096 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
1098 $this->assertEquals($attempt, (object) $result['attempt']);
1099 $this->assertEquals(-1, $result['nextpage']);
1100 $this->assertCount(0, $result['messages']);
1101 $this->assertCount(1, $result['questions']);
1102 $this->assertEquals(2, $result['questions'][0]['slot']);
1103 $this->assertEquals(2, $result['questions'][0]['questionnumber']);
1104 $this->assertEquals(2, $result['questions'][0]['number']);
1105 $this->assertEquals('numerical', $result['questions'][0]['type']);
1106 $this->assertArrayNotHasKey('state', $result['questions'][0]); // We don't receive the state yet.
1107 $this->assertEquals(get_string('notyetanswered', 'question'), $result['questions'][0]['status']);
1108 $this->assertFalse($result['questions'][0]['flagged']);
1109 $this->assertEquals(1, $result['questions'][0]['page']);
1110 $this->assertEquals(1, $result['questions'][0]['sequencecheck']);
1111 $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1112 $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1114 // Finish previous attempt.
1115 $attemptobj->process_finish(time(), false);
1117 // Now we should receive the question state.
1118 $result = mod_quiz_external::get_attempt_review($attempt->id, 1);
1119 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result);
1120 $this->assertEquals('gaveup', $result['questions'][0]['state']);
1122 // Change setting and expect two pages.
1123 $quiz->questionsperpage = 4;
1124 $DB->update_record('quiz', $quiz);
1125 quiz_repaginate_questions($quiz->id, $quiz->questionsperpage);
1127 // Start with new attempt with the new layout.
1128 $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
1129 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
1131 $timenow = time();
1132 $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id);
1133 quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
1134 quiz_attempt_save_started($quizobj, $quba, $attempt);
1136 // We receive two questions per page.
1137 $result = mod_quiz_external::get_attempt_data($attempt->id, 0);
1138 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
1139 $this->assertCount(2, $result['questions']);
1140 $this->assertEquals(-1, $result['nextpage']);
1142 // Check questions looks good.
1143 $found = 0;
1144 foreach ($questions as $question) {
1145 foreach ($result['questions'] as $rquestion) {
1146 if ($rquestion['slot'] == $question->slot) {
1147 $this->assertTrue(strpos($rquestion['html'], "qid=$question->id") !== false);
1148 $found++;
1152 $this->assertEquals(2, $found);
1157 * Test get_attempt_data with blocked questions.
1158 * @since 3.2
1160 public function test_get_attempt_data_with_blocked_questions() {
1161 global $DB;
1163 // Create a new quiz with one attempt started and using immediatefeedback.
1164 list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(
1165 true, false, 'immediatefeedback');
1167 $quizobj = $attemptobj->get_quizobj();
1169 // Make second question blocked by the first one.
1170 $structure = $quizobj->get_structure();
1171 $slots = $structure->get_slots();
1172 $structure->update_question_dependency(end($slots)->id, true);
1174 $quizobj->preload_questions();
1175 $quizobj->load_questions();
1176 $questions = $quizobj->get_questions();
1178 $this->setUser($this->student);
1180 // We receive one question per page.
1181 $result = mod_quiz_external::get_attempt_data($attempt->id, 0);
1182 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
1184 $this->assertEquals($attempt, (object) $result['attempt']);
1185 $this->assertCount(1, $result['questions']);
1186 $this->assertEquals(1, $result['questions'][0]['slot']);
1187 $this->assertEquals(1, $result['questions'][0]['number']);
1188 $this->assertEquals(false, $result['questions'][0]['blockedbyprevious']);
1190 // Now try the last page.
1191 $result = mod_quiz_external::get_attempt_data($attempt->id, 1);
1192 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
1194 $this->assertEquals($attempt, (object) $result['attempt']);
1195 $this->assertCount(1, $result['questions']);
1196 $this->assertEquals(2, $result['questions'][0]['slot']);
1197 $this->assertEquals(2, $result['questions'][0]['number']);
1198 $this->assertEquals(true, $result['questions'][0]['blockedbyprevious']);
1202 * Test get_attempt_summary
1204 public function test_get_attempt_summary() {
1206 $timenow = time();
1207 // Create a new quiz with one attempt started.
1208 list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true);
1210 $this->setUser($this->student);
1211 $result = mod_quiz_external::get_attempt_summary($attempt->id);
1212 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1214 // Check the state, flagged and mark data is correct.
1215 $this->assertEquals('todo', $result['questions'][0]['state']);
1216 $this->assertEquals('todo', $result['questions'][1]['state']);
1217 $this->assertEquals(1, $result['questions'][0]['number']);
1218 $this->assertEquals(2, $result['questions'][1]['number']);
1219 $this->assertFalse($result['questions'][0]['flagged']);
1220 $this->assertFalse($result['questions'][1]['flagged']);
1221 $this->assertEmpty($result['questions'][0]['mark']);
1222 $this->assertEmpty($result['questions'][1]['mark']);
1223 $this->assertEquals(1, $result['questions'][0]['sequencecheck']);
1224 $this->assertEquals(1, $result['questions'][1]['sequencecheck']);
1225 $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1226 $this->assertGreaterThanOrEqual($timenow, $result['questions'][1]['lastactiontime']);
1227 $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1228 $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']);
1230 // Check question options.
1231 $this->assertNotEmpty(5, $result['questions'][0]['settings']);
1232 // Check at least some settings returned.
1233 $this->assertCount(4, (array) json_decode($result['questions'][0]['settings']));
1235 // Submit a response for the first question.
1236 $tosubmit = [1 => ['answer' => '3.14']];
1237 $attemptobj->process_submitted_actions(time(), false, $tosubmit);
1238 $result = mod_quiz_external::get_attempt_summary($attempt->id);
1239 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1241 // Check it's marked as completed only the first one.
1242 $this->assertEquals('complete', $result['questions'][0]['state']);
1243 $this->assertEquals('todo', $result['questions'][1]['state']);
1244 $this->assertEquals(1, $result['questions'][0]['number']);
1245 $this->assertEquals(2, $result['questions'][1]['number']);
1246 $this->assertFalse($result['questions'][0]['flagged']);
1247 $this->assertFalse($result['questions'][1]['flagged']);
1248 $this->assertEmpty($result['questions'][0]['mark']);
1249 $this->assertEmpty($result['questions'][1]['mark']);
1250 $this->assertEquals(2, $result['questions'][0]['sequencecheck']);
1251 $this->assertEquals(1, $result['questions'][1]['sequencecheck']);
1252 $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1253 $this->assertGreaterThanOrEqual($timenow, $result['questions'][1]['lastactiontime']);
1254 $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1255 $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']);
1260 * Test save_attempt
1262 public function test_save_attempt() {
1264 $timenow = time();
1265 // Create a new quiz with one attempt started.
1266 list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true);
1268 // Response for slot 1.
1269 $prefix = $quba->get_field_prefix(1);
1270 $data = [
1271 ['name' => 'slots', 'value' => 1],
1272 ['name' => $prefix . ':sequencecheck',
1273 'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()],
1274 ['name' => $prefix . 'answer', 'value' => 1],
1277 $this->setUser($this->student);
1279 $result = mod_quiz_external::save_attempt($attempt->id, $data);
1280 $result = external_api::clean_returnvalue(mod_quiz_external::save_attempt_returns(), $result);
1281 $this->assertTrue($result['status']);
1283 // Now, get the summary.
1284 $result = mod_quiz_external::get_attempt_summary($attempt->id);
1285 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1287 // Check it's marked as completed only the first one.
1288 $this->assertEquals('complete', $result['questions'][0]['state']);
1289 $this->assertEquals('todo', $result['questions'][1]['state']);
1290 $this->assertEquals(1, $result['questions'][0]['number']);
1291 $this->assertEquals(2, $result['questions'][1]['number']);
1292 $this->assertFalse($result['questions'][0]['flagged']);
1293 $this->assertFalse($result['questions'][1]['flagged']);
1294 $this->assertEmpty($result['questions'][0]['mark']);
1295 $this->assertEmpty($result['questions'][1]['mark']);
1296 $this->assertEquals(1, $result['questions'][0]['sequencecheck']);
1297 $this->assertEquals(1, $result['questions'][1]['sequencecheck']);
1298 $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1299 $this->assertGreaterThanOrEqual($timenow, $result['questions'][1]['lastactiontime']);
1300 $this->assertEquals(true, $result['questions'][0]['hasautosavedstep']);
1301 $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']);
1303 // Now, second slot.
1304 $prefix = $quba->get_field_prefix(2);
1305 $data = [
1306 ['name' => 'slots', 'value' => 2],
1307 ['name' => $prefix . ':sequencecheck',
1308 'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()],
1309 ['name' => $prefix . 'answer', 'value' => 1],
1312 $result = mod_quiz_external::save_attempt($attempt->id, $data);
1313 $result = external_api::clean_returnvalue(mod_quiz_external::save_attempt_returns(), $result);
1314 $this->assertTrue($result['status']);
1316 // Now, get the summary.
1317 $result = mod_quiz_external::get_attempt_summary($attempt->id);
1318 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1320 // Check it's marked as completed only the first one.
1321 $this->assertEquals('complete', $result['questions'][0]['state']);
1322 $this->assertEquals(1, $result['questions'][0]['sequencecheck']);
1323 $this->assertEquals('complete', $result['questions'][1]['state']);
1324 $this->assertEquals(1, $result['questions'][1]['sequencecheck']);
1329 * Test process_attempt
1331 public function test_process_attempt() {
1332 global $DB;
1334 $timenow = time();
1335 // Create a new quiz with three questions and one attempt started.
1336 list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, false,
1337 'deferredfeedback', true);
1339 // Response for slot 1.
1340 $prefix = $quba->get_field_prefix(1);
1341 $data = [
1342 ['name' => 'slots', 'value' => 1],
1343 ['name' => $prefix . ':sequencecheck',
1344 'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()],
1345 ['name' => $prefix . 'answer', 'value' => 1],
1348 $this->setUser($this->student);
1350 $result = mod_quiz_external::process_attempt($attempt->id, $data);
1351 $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1352 $this->assertEquals(quiz_attempt::IN_PROGRESS, $result['state']);
1354 $result = mod_quiz_external::get_attempt_data($attempt->id, 2);
1356 // Now, get the summary.
1357 $result = mod_quiz_external::get_attempt_summary($attempt->id);
1358 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1360 // Check it's marked as completed only the first one.
1361 $this->assertEquals('complete', $result['questions'][0]['state']);
1362 $this->assertEquals('todo', $result['questions'][1]['state']);
1363 $this->assertEquals(1, $result['questions'][0]['number']);
1364 $this->assertEquals(2, $result['questions'][1]['number']);
1365 $this->assertFalse($result['questions'][0]['flagged']);
1366 $this->assertFalse($result['questions'][1]['flagged']);
1367 $this->assertEmpty($result['questions'][0]['mark']);
1368 $this->assertEmpty($result['questions'][1]['mark']);
1369 $this->assertEquals(2, $result['questions'][0]['sequencecheck']);
1370 $this->assertEquals(2, $result['questions'][0]['sequencecheck']);
1371 $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1372 $this->assertGreaterThanOrEqual($timenow, $result['questions'][0]['lastactiontime']);
1373 $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1374 $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
1376 // Now, second slot.
1377 $prefix = $quba->get_field_prefix(2);
1378 $data = [
1379 ['name' => 'slots', 'value' => 2],
1380 ['name' => $prefix . ':sequencecheck',
1381 'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()],
1382 ['name' => $prefix . 'answer', 'value' => 1],
1383 ['name' => $prefix . ':flagged', 'value' => 1],
1386 $result = mod_quiz_external::process_attempt($attempt->id, $data);
1387 $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1388 $this->assertEquals(quiz_attempt::IN_PROGRESS, $result['state']);
1390 // Now, get the summary.
1391 $result = mod_quiz_external::get_attempt_summary($attempt->id);
1392 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1394 // Check it's marked as completed the two first questions.
1395 $this->assertEquals('complete', $result['questions'][0]['state']);
1396 $this->assertEquals('complete', $result['questions'][1]['state']);
1397 $this->assertFalse($result['questions'][0]['flagged']);
1398 $this->assertTrue($result['questions'][1]['flagged']);
1400 // Add files in the attachment response.
1401 $draftitemid = file_get_unused_draft_itemid();
1402 $filerecordinline = [
1403 'contextid' => \context_user::instance($this->student->id)->id,
1404 'component' => 'user',
1405 'filearea' => 'draft',
1406 'itemid' => $draftitemid,
1407 'filepath' => '/',
1408 'filename' => 'faketxt.txt',
1410 $fs = get_file_storage();
1411 $fs->create_file_from_string($filerecordinline, 'fake txt contents 1.');
1413 // Last slot.
1414 $prefix = $quba->get_field_prefix(3);
1415 $data = [
1416 ['name' => 'slots', 'value' => 3],
1417 ['name' => $prefix . ':sequencecheck',
1418 'value' => $attemptobj->get_question_attempt(1)->get_sequence_check_count()],
1419 ['name' => $prefix . 'answer', 'value' => 'Some test'],
1420 ['name' => $prefix . 'answerformat', 'value' => FORMAT_HTML],
1421 ['name' => $prefix . 'attachments', 'value' => $draftitemid],
1424 $result = mod_quiz_external::process_attempt($attempt->id, $data);
1425 $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1426 $this->assertEquals(quiz_attempt::IN_PROGRESS, $result['state']);
1428 // Now, get the summary.
1429 $result = mod_quiz_external::get_attempt_summary($attempt->id);
1430 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_summary_returns(), $result);
1432 $this->assertEquals('complete', $result['questions'][0]['state']);
1433 $this->assertEquals('complete', $result['questions'][1]['state']);
1434 $this->assertEquals('complete', $result['questions'][2]['state']);
1435 $this->assertFalse($result['questions'][0]['flagged']);
1436 $this->assertTrue($result['questions'][1]['flagged']);
1437 $this->assertFalse($result['questions'][2]['flagged']);
1439 // Check submitted files are there.
1440 $this->assertCount(1, $result['questions'][2]['responsefileareas']);
1441 $this->assertEquals('attachments', $result['questions'][2]['responsefileareas'][0]['area']);
1442 $this->assertCount(1, $result['questions'][2]['responsefileareas'][0]['files']);
1443 $this->assertEquals($filerecordinline['filename'], $result['questions'][2]['responsefileareas'][0]['files'][0]['filename']);
1445 // Finish the attempt.
1446 $sink = $this->redirectMessages();
1447 $result = mod_quiz_external::process_attempt($attempt->id, [], true);
1448 $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1449 $this->assertEquals(quiz_attempt::FINISHED, $result['state']);
1450 $messages = $sink->get_messages();
1451 $message = reset($messages);
1452 $sink->close();
1453 // Test customdata.
1454 if (!empty($message->customdata)) {
1455 $customdata = json_decode($message->customdata);
1456 $this->assertEquals($quizobj->get_quizid(), $customdata->instance);
1457 $this->assertEquals($quizobj->get_cmid(), $customdata->cmid);
1458 $this->assertEquals($attempt->id, $customdata->attemptid);
1459 $this->assertObjectHasAttribute('notificationiconurl', $customdata);
1462 // Start new attempt.
1463 $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
1464 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
1466 $timenow = time();
1467 $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id);
1468 quiz_start_new_attempt($quizobj, $quba, $attempt, 2, $timenow);
1469 quiz_attempt_save_started($quizobj, $quba, $attempt);
1471 // Force grace period, attempt going to overdue.
1472 $quiz->timeclose = $timenow - 10;
1473 $quiz->graceperiod = 60;
1474 $quiz->overduehandling = 'graceperiod';
1475 $DB->update_record('quiz', $quiz);
1477 $result = mod_quiz_external::process_attempt($attempt->id, []);
1478 $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1479 $this->assertEquals(quiz_attempt::OVERDUE, $result['state']);
1481 // Force grace period for time limit.
1482 $quiz->timeclose = 0;
1483 $quiz->timelimit = 1;
1484 $quiz->graceperiod = 60;
1485 $quiz->overduehandling = 'graceperiod';
1486 $DB->update_record('quiz', $quiz);
1488 $timenow = time();
1489 $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
1490 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
1491 $attempt = quiz_create_attempt($quizobj, 3, 2, $timenow - 10, false, $this->student->id);
1492 quiz_start_new_attempt($quizobj, $quba, $attempt, 2, $timenow - 10);
1493 quiz_attempt_save_started($quizobj, $quba, $attempt);
1495 $result = mod_quiz_external::process_attempt($attempt->id, []);
1496 $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1497 $this->assertEquals(quiz_attempt::OVERDUE, $result['state']);
1499 // New attempt.
1500 $timenow = time();
1501 $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
1502 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
1503 $attempt = quiz_create_attempt($quizobj, 4, 3, $timenow, false, $this->student->id);
1504 quiz_start_new_attempt($quizobj, $quba, $attempt, 3, $timenow);
1505 quiz_attempt_save_started($quizobj, $quba, $attempt);
1507 // Force abandon.
1508 $quiz->timeclose = $timenow - HOURSECS;
1509 $DB->update_record('quiz', $quiz);
1511 $result = mod_quiz_external::process_attempt($attempt->id, []);
1512 $result = external_api::clean_returnvalue(mod_quiz_external::process_attempt_returns(), $result);
1513 $this->assertEquals(quiz_attempt::ABANDONED, $result['state']);
1518 * Test validate_attempt_review
1520 public function test_validate_attempt_review() {
1521 global $DB;
1523 // Create a new quiz with one attempt started.
1524 list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true);
1526 $this->setUser($this->student);
1528 // Invalid attempt, invalid id.
1529 try {
1530 $params = ['attemptid' => -1];
1531 testable_mod_quiz_external::validate_attempt_review($params);
1532 $this->fail('Exception expected due invalid id.');
1533 } catch (\dml_missing_record_exception $e) {
1534 $this->assertEquals('invalidrecord', $e->errorcode);
1537 // Invalid attempt, not closed.
1538 try {
1539 $params = ['attemptid' => $attempt->id];
1540 testable_mod_quiz_external::validate_attempt_review($params);
1541 $this->fail('Exception expected due not closed attempt.');
1542 } catch (moodle_exception $e) {
1543 $this->assertEquals('attemptclosed', $e->errorcode);
1546 // Test ok case (finished attempt).
1547 list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true, true);
1549 $params = ['attemptid' => $attempt->id];
1550 testable_mod_quiz_external::validate_attempt_review($params);
1552 // Teacher should be able to view the review of one student's attempt.
1553 $this->setUser($this->teacher);
1554 testable_mod_quiz_external::validate_attempt_review($params);
1556 // We should not see other students attempts.
1557 $anotherstudent = self::getDataGenerator()->create_user();
1558 $this->getDataGenerator()->enrol_user($anotherstudent->id, $this->course->id, $this->studentrole->id, 'manual');
1560 $this->setUser($anotherstudent);
1561 try {
1562 $params = ['attemptid' => $attempt->id];
1563 testable_mod_quiz_external::validate_attempt_review($params);
1564 $this->fail('Exception expected due missing permissions.');
1565 } catch (moodle_exception $e) {
1566 $this->assertEquals('noreviewattempt', $e->errorcode);
1572 * Test get_attempt_review
1574 public function test_get_attempt_review() {
1575 global $DB;
1577 // Create a new quiz with two questions and one attempt finished.
1578 list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, true);
1580 // Add feedback to the quiz.
1581 $feedback = new \stdClass();
1582 $feedback->quizid = $quiz->id;
1583 $feedback->feedbacktext = 'Feedback text 1';
1584 $feedback->feedbacktextformat = 1;
1585 $feedback->mingrade = 49;
1586 $feedback->maxgrade = 100;
1587 $feedback->id = $DB->insert_record('quiz_feedback', $feedback);
1589 $feedback->feedbacktext = 'Feedback text 2';
1590 $feedback->feedbacktextformat = 1;
1591 $feedback->mingrade = 30;
1592 $feedback->maxgrade = 48;
1593 $feedback->id = $DB->insert_record('quiz_feedback', $feedback);
1595 $result = mod_quiz_external::get_attempt_review($attempt->id);
1596 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result);
1598 // Two questions, one completed and correct, the other gave up.
1599 $this->assertEquals(50, $result['grade']);
1600 $this->assertEquals(1, $result['attempt']['attempt']);
1601 $this->assertEquals('finished', $result['attempt']['state']);
1602 $this->assertEquals(1, $result['attempt']['sumgrades']);
1603 $this->assertCount(2, $result['questions']);
1604 $this->assertEquals('gradedright', $result['questions'][0]['state']);
1605 $this->assertEquals(1, $result['questions'][0]['slot']);
1606 $this->assertEquals('gaveup', $result['questions'][1]['state']);
1607 $this->assertEquals(2, $result['questions'][1]['slot']);
1609 $this->assertCount(1, $result['additionaldata']);
1610 $this->assertEquals('feedback', $result['additionaldata'][0]['id']);
1611 $this->assertEquals('Feedback', $result['additionaldata'][0]['title']);
1612 $this->assertEquals('Feedback text 1', $result['additionaldata'][0]['content']);
1614 // Only first page.
1615 $result = mod_quiz_external::get_attempt_review($attempt->id, 0);
1616 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result);
1618 $this->assertEquals(50, $result['grade']);
1619 $this->assertEquals(1, $result['attempt']['attempt']);
1620 $this->assertEquals('finished', $result['attempt']['state']);
1621 $this->assertEquals(1, $result['attempt']['sumgrades']);
1622 $this->assertCount(1, $result['questions']);
1623 $this->assertEquals('gradedright', $result['questions'][0]['state']);
1624 $this->assertEquals(1, $result['questions'][0]['slot']);
1626 $this->assertCount(1, $result['additionaldata']);
1627 $this->assertEquals('feedback', $result['additionaldata'][0]['id']);
1628 $this->assertEquals('Feedback', $result['additionaldata'][0]['title']);
1629 $this->assertEquals('Feedback text 1', $result['additionaldata'][0]['content']);
1634 * Test test_view_attempt
1636 public function test_view_attempt() {
1637 global $DB;
1639 // Create a new quiz with two questions and one attempt started.
1640 list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, false);
1642 // Test user with full capabilities.
1643 $this->setUser($this->student);
1645 // Trigger and capture the event.
1646 $sink = $this->redirectEvents();
1648 $result = mod_quiz_external::view_attempt($attempt->id, 0);
1649 $result = external_api::clean_returnvalue(mod_quiz_external::view_attempt_returns(), $result);
1650 $this->assertTrue($result['status']);
1652 $events = $sink->get_events();
1653 $this->assertCount(1, $events);
1654 $event = array_shift($events);
1656 // Checking that the event contains the expected values.
1657 $this->assertInstanceOf('\mod_quiz\event\attempt_viewed', $event);
1658 $this->assertEquals($context, $event->get_context());
1659 $this->assertEventContextNotUsed($event);
1660 $this->assertNotEmpty($event->get_name());
1662 // Now, force the quiz with QUIZ_NAVMETHOD_SEQ (sequential) navigation method.
1663 $DB->set_field('quiz', 'navmethod', QUIZ_NAVMETHOD_SEQ, ['id' => $quiz->id]);
1664 // Quiz requiring preflightdata.
1665 $DB->set_field('quiz', 'password', 'abcdef', ['id' => $quiz->id]);
1666 $preflightdata = [["name" => "quizpassword", "value" => 'abcdef']];
1668 // See next page.
1669 $result = mod_quiz_external::view_attempt($attempt->id, 1, $preflightdata);
1670 $result = external_api::clean_returnvalue(mod_quiz_external::view_attempt_returns(), $result);
1671 $this->assertTrue($result['status']);
1673 $events = $sink->get_events();
1674 $this->assertCount(2, $events);
1676 // Try to go to previous page.
1677 try {
1678 mod_quiz_external::view_attempt($attempt->id, 0);
1679 $this->fail('Exception expected due to try to see a previous page.');
1680 } catch (moodle_exception $e) {
1681 $this->assertEquals('Out of sequence access', $e->errorcode);
1687 * Test test_view_attempt_summary
1689 public function test_view_attempt_summary() {
1690 global $DB;
1692 // Create a new quiz with two questions and one attempt started.
1693 list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, false);
1695 // Test user with full capabilities.
1696 $this->setUser($this->student);
1698 // Trigger and capture the event.
1699 $sink = $this->redirectEvents();
1701 $result = mod_quiz_external::view_attempt_summary($attempt->id);
1702 $result = external_api::clean_returnvalue(mod_quiz_external::view_attempt_summary_returns(), $result);
1703 $this->assertTrue($result['status']);
1705 $events = $sink->get_events();
1706 $this->assertCount(1, $events);
1707 $event = array_shift($events);
1709 // Checking that the event contains the expected values.
1710 $this->assertInstanceOf('\mod_quiz\event\attempt_summary_viewed', $event);
1711 $this->assertEquals($context, $event->get_context());
1712 $moodlequiz = new \moodle_url('/mod/quiz/summary.php', ['attempt' => $attempt->id]);
1713 $this->assertEquals($moodlequiz, $event->get_url());
1714 $this->assertEventContextNotUsed($event);
1715 $this->assertNotEmpty($event->get_name());
1717 // Quiz requiring preflightdata.
1718 $DB->set_field('quiz', 'password', 'abcdef', ['id' => $quiz->id]);
1719 $preflightdata = [["name" => "quizpassword", "value" => 'abcdef']];
1721 $result = mod_quiz_external::view_attempt_summary($attempt->id, $preflightdata);
1722 $result = external_api::clean_returnvalue(mod_quiz_external::view_attempt_summary_returns(), $result);
1723 $this->assertTrue($result['status']);
1728 * Test test_view_attempt_summary
1730 public function test_view_attempt_review() {
1731 global $DB;
1733 // Create a new quiz with two questions and one attempt finished.
1734 list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, true);
1736 // Test user with full capabilities.
1737 $this->setUser($this->student);
1739 // Trigger and capture the event.
1740 $sink = $this->redirectEvents();
1742 $result = mod_quiz_external::view_attempt_review($attempt->id, 0);
1743 $result = external_api::clean_returnvalue(mod_quiz_external::view_attempt_review_returns(), $result);
1744 $this->assertTrue($result['status']);
1746 $events = $sink->get_events();
1747 $this->assertCount(1, $events);
1748 $event = array_shift($events);
1750 // Checking that the event contains the expected values.
1751 $this->assertInstanceOf('\mod_quiz\event\attempt_reviewed', $event);
1752 $this->assertEquals($context, $event->get_context());
1753 $moodlequiz = new \moodle_url('/mod/quiz/review.php', ['attempt' => $attempt->id]);
1754 $this->assertEquals($moodlequiz, $event->get_url());
1755 $this->assertEventContextNotUsed($event);
1756 $this->assertNotEmpty($event->get_name());
1761 * Test get_quiz_feedback_for_grade
1763 public function test_get_quiz_feedback_for_grade() {
1764 global $DB;
1766 // Add feedback to the quiz.
1767 $feedback = new \stdClass();
1768 $feedback->quizid = $this->quiz->id;
1769 $feedback->feedbacktext = 'Feedback text 1';
1770 $feedback->feedbacktextformat = 1;
1771 $feedback->mingrade = 49;
1772 $feedback->maxgrade = 100;
1773 $feedback->id = $DB->insert_record('quiz_feedback', $feedback);
1774 // Add a fake inline image to the feedback text.
1775 $filename = 'shouldbeanimage.jpg';
1776 $filerecordinline = [
1777 'contextid' => $this->context->id,
1778 'component' => 'mod_quiz',
1779 'filearea' => 'feedback',
1780 'itemid' => $feedback->id,
1781 'filepath' => '/',
1782 'filename' => $filename,
1784 $fs = get_file_storage();
1785 $fs->create_file_from_string($filerecordinline, 'image contents (not really)');
1787 $feedback->feedbacktext = 'Feedback text 2';
1788 $feedback->feedbacktextformat = 1;
1789 $feedback->mingrade = 30;
1790 $feedback->maxgrade = 49;
1791 $feedback->id = $DB->insert_record('quiz_feedback', $feedback);
1793 $result = mod_quiz_external::get_quiz_feedback_for_grade($this->quiz->id, 50);
1794 $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_feedback_for_grade_returns(), $result);
1795 $this->assertEquals('Feedback text 1', $result['feedbacktext']);
1796 $this->assertEquals($filename, $result['feedbackinlinefiles'][0]['filename']);
1797 $this->assertEquals(FORMAT_HTML, $result['feedbacktextformat']);
1799 $result = mod_quiz_external::get_quiz_feedback_for_grade($this->quiz->id, 30);
1800 $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_feedback_for_grade_returns(), $result);
1801 $this->assertEquals('Feedback text 2', $result['feedbacktext']);
1802 $this->assertEquals(FORMAT_HTML, $result['feedbacktextformat']);
1804 $result = mod_quiz_external::get_quiz_feedback_for_grade($this->quiz->id, 10);
1805 $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_feedback_for_grade_returns(), $result);
1806 $this->assertEquals('', $result['feedbacktext']);
1807 $this->assertEquals(FORMAT_MOODLE, $result['feedbacktextformat']);
1811 * Test get_quiz_access_information
1813 public function test_get_quiz_access_information() {
1814 global $DB;
1816 // Create a new quiz.
1817 $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
1818 $data = ['course' => $this->course->id];
1819 $quiz = $quizgenerator->create_instance($data);
1821 $this->setUser($this->student);
1823 // Default restrictions (none).
1824 $result = mod_quiz_external::get_quiz_access_information($quiz->id);
1825 $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_access_information_returns(), $result);
1827 $expected = [
1828 'canattempt' => true,
1829 'canmanage' => false,
1830 'canpreview' => false,
1831 'canreviewmyattempts' => true,
1832 'canviewreports' => false,
1833 'accessrules' => [],
1834 // This rule is always used, even if the quiz has no open or close date.
1835 'activerulenames' => ['quizaccess_openclosedate'],
1836 'preventaccessreasons' => [],
1837 'warnings' => []
1840 $this->assertEquals($expected, $result);
1842 // Now teacher, different privileges.
1843 $this->setUser($this->teacher);
1844 $result = mod_quiz_external::get_quiz_access_information($quiz->id);
1845 $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_access_information_returns(), $result);
1847 $expected['canmanage'] = true;
1848 $expected['canpreview'] = true;
1849 $expected['canviewreports'] = true;
1850 $expected['canattempt'] = false;
1851 $expected['canreviewmyattempts'] = false;
1853 $this->assertEquals($expected, $result);
1855 $this->setUser($this->student);
1856 // Now add some restrictions.
1857 $quiz->timeopen = time() + DAYSECS;
1858 $quiz->timeclose = time() + WEEKSECS;
1859 $quiz->password = '123456';
1860 $DB->update_record('quiz', $quiz);
1862 $result = mod_quiz_external::get_quiz_access_information($quiz->id);
1863 $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_access_information_returns(), $result);
1865 // Access is limited by time and password, but only the password limit has a description.
1866 $this->assertCount(1, $result['accessrules']);
1867 // Two rule names, password and open/close date.
1868 $this->assertCount(2, $result['activerulenames']);
1869 $this->assertCount(1, $result['preventaccessreasons']);
1874 * Test get_attempt_access_information
1876 public function test_get_attempt_access_information() {
1877 global $DB;
1879 $this->setAdminUser();
1881 // Create a new quiz with attempts.
1882 $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
1883 $data = ['course' => $this->course->id,
1884 'sumgrades' => 2];
1885 $quiz = $quizgenerator->create_instance($data);
1887 // Create some questions.
1888 $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
1890 $cat = $questiongenerator->create_question_category();
1891 $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
1892 quiz_add_quiz_question($question->id, $quiz);
1894 $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
1895 quiz_add_quiz_question($question->id, $quiz);
1897 // Add new question types in the category (for the random one).
1898 $question = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]);
1899 $question = $questiongenerator->create_question('essay', null, ['category' => $cat->id]);
1901 quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
1903 $quizobj = quiz_settings::create($quiz->id, $this->student->id);
1905 // Set grade to pass.
1906 $item = \grade_item::fetch(['courseid' => $this->course->id, 'itemtype' => 'mod',
1907 'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null]);
1908 $item->gradepass = 80;
1909 $item->update();
1911 $this->setUser($this->student);
1913 // Default restrictions (none).
1914 $result = mod_quiz_external::get_attempt_access_information($quiz->id);
1915 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_access_information_returns(), $result);
1917 $expected = [
1918 'isfinished' => false,
1919 'preventnewattemptreasons' => [],
1920 'warnings' => []
1923 $this->assertEquals($expected, $result);
1925 // Limited attempts.
1926 $quiz->attempts = 1;
1927 $DB->update_record('quiz', $quiz);
1929 // Now, do one attempt.
1930 $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
1931 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
1933 $timenow = time();
1934 $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id);
1935 quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
1936 quiz_attempt_save_started($quizobj, $quba, $attempt);
1938 // Process some responses from the student.
1939 $attemptobj = quiz_attempt::create($attempt->id);
1940 $tosubmit = [1 => ['answer' => '3.14']];
1941 $attemptobj->process_submitted_actions($timenow, false, $tosubmit);
1943 // Finish the attempt.
1944 $attemptobj = quiz_attempt::create($attempt->id);
1945 $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question());
1946 $attemptobj->process_finish($timenow, false);
1948 // Can we start a new attempt? We shall not!
1949 $result = mod_quiz_external::get_attempt_access_information($quiz->id, $attempt->id);
1950 $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_access_information_returns(), $result);
1952 // Now new attemps allowed.
1953 $this->assertCount(1, $result['preventnewattemptreasons']);
1954 $this->assertFalse($result['ispreflightcheckrequired']);
1955 $this->assertEquals(get_string('nomoreattempts', 'quiz'), $result['preventnewattemptreasons'][0]);
1960 * Test get_quiz_required_qtypes
1962 public function test_get_quiz_required_qtypes() {
1963 $this->setAdminUser();
1965 // Create a new quiz.
1966 $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
1967 $data = ['course' => $this->course->id];
1968 $quiz = $quizgenerator->create_instance($data);
1970 // Create some questions.
1971 $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
1973 $cat = $questiongenerator->create_question_category();
1974 $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
1975 quiz_add_quiz_question($question->id, $quiz);
1977 $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
1978 quiz_add_quiz_question($question->id, $quiz);
1980 $question = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]);
1981 quiz_add_quiz_question($question->id, $quiz);
1983 $question = $questiongenerator->create_question('essay', null, ['category' => $cat->id]);
1984 quiz_add_quiz_question($question->id, $quiz);
1986 $question = $questiongenerator->create_question('multichoice', null,
1987 ['category' => $cat->id, 'status' => question_version_status::QUESTION_STATUS_DRAFT]);
1988 quiz_add_quiz_question($question->id, $quiz);
1990 $this->setUser($this->student);
1992 $result = mod_quiz_external::get_quiz_required_qtypes($quiz->id);
1993 $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_required_qtypes_returns(), $result);
1995 $expected = [
1996 'questiontypes' => ['essay', 'numerical', 'shortanswer', 'truefalse'],
1997 'warnings' => []
2000 $this->assertEquals($expected, $result);
2005 * Test get_quiz_required_qtypes for quiz with random questions
2007 public function test_get_quiz_required_qtypes_random() {
2008 $this->setAdminUser();
2010 // Create a new quiz.
2011 $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
2012 $quiz = $quizgenerator->create_instance(['course' => $this->course->id]);
2014 // Create some questions.
2015 $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
2017 $cat = $questiongenerator->create_question_category();
2018 $anothercat = $questiongenerator->create_question_category();
2020 $question = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
2021 $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
2022 $question = $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]);
2023 // Question in a different category.
2024 $question = $questiongenerator->create_question('essay', null, ['category' => $anothercat->id]);
2026 // Add a couple of random questions from the same category.
2027 quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
2028 quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
2030 $this->setUser($this->student);
2032 $result = mod_quiz_external::get_quiz_required_qtypes($quiz->id);
2033 $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_required_qtypes_returns(), $result);
2035 $expected = ['numerical', 'shortanswer', 'truefalse'];
2036 ksort($result['questiontypes']);
2038 $this->assertEquals($expected, $result['questiontypes']);
2040 // Add more questions to the quiz, this time from the other category.
2041 $this->setAdminUser();
2042 quiz_add_random_questions($quiz, 0, $anothercat->id, 1, false);
2044 $this->setUser($this->student);
2045 $result = mod_quiz_external::get_quiz_required_qtypes($quiz->id);
2046 $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_required_qtypes_returns(), $result);
2048 // The new question from the new category is returned as a potential random question for the quiz.
2049 $expected = ['essay', 'numerical', 'shortanswer', 'truefalse'];
2050 ksort($result['questiontypes']);
2052 $this->assertEquals($expected, $result['questiontypes']);
2056 * Test that a sequential navigation quiz is not allowing to see questions in advance except if reviewing
2058 public function test_sequential_navigation_view_attempt() {
2059 // Test user with full capabilities.
2060 $quiz = $this->prepare_sequential_quiz();
2061 $attemptobj = $this->create_quiz_attempt_object($quiz);
2062 $this->setUser($this->student);
2063 // Check out of sequence access for view.
2064 $this->assertNotEmpty(mod_quiz_external::view_attempt($attemptobj->get_attemptid(), 0, []));
2065 try {
2066 mod_quiz_external::view_attempt($attemptobj->get_attemptid(), 3, []);
2067 $this->fail('Exception expected due to out of sequence access.');
2068 } catch (moodle_exception $e) {
2069 $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage());
2074 * Test that a sequential navigation quiz is not allowing to see questions in advance for a student
2076 public function test_sequential_navigation_attempt_summary() {
2077 // Test user with full capabilities.
2078 $quiz = $this->prepare_sequential_quiz();
2079 $attemptobj = $this->create_quiz_attempt_object($quiz);
2080 $this->setUser($this->student);
2081 // Check that we do not return other questions than the one currently viewed.
2082 $result = mod_quiz_external::get_attempt_summary($attemptobj->get_attemptid());
2083 $this->assertCount(1, $result['questions']);
2084 $this->assertStringContainsString('Question (1)', $result['questions'][0]['html']);
2088 * Test that a sequential navigation quiz is not allowing to see questions in advance for student
2090 public function test_sequential_navigation_get_attempt_data() {
2091 // Test user with full capabilities.
2092 $quiz = $this->prepare_sequential_quiz();
2093 $attemptobj = $this->create_quiz_attempt_object($quiz);
2094 $this->setUser($this->student);
2095 // Test invalid instance id.
2096 try {
2097 mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 2);
2098 $this->fail('Exception expected due to out of sequence access.');
2099 } catch (moodle_exception $e) {
2100 $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage());
2102 // Now we moved to page 1, we should see page 2 and 1 but not 0 or 3.
2103 $attemptobj->set_currentpage(1);
2104 // Test invalid instance id.
2105 try {
2106 mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 0);
2107 $this->fail('Exception expected due to out of sequence access.');
2108 } catch (moodle_exception $e) {
2109 $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage());
2112 try {
2113 mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 3);
2114 $this->fail('Exception expected due to out of sequence access.');
2115 } catch (moodle_exception $e) {
2116 $this->assertStringContainsString('quiz/Out of sequence access', $e->getMessage());
2119 // Now we can see page 1.
2120 $result = mod_quiz_external::get_attempt_data($attemptobj->get_attemptid(), 1);
2121 $this->assertCount(1, $result['questions']);
2122 $this->assertStringContainsString('Question (2)', $result['questions'][0]['html']);
2126 * Prepare quiz for sequential navigation tests
2128 * @return quiz_settings
2130 private function prepare_sequential_quiz(): quiz_settings {
2131 // Create a new quiz with 5 questions and one attempt started.
2132 // Create a new quiz with attempts.
2133 $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
2134 $data = [
2135 'course' => $this->course->id,
2136 'sumgrades' => 2,
2137 'preferredbehaviour' => 'deferredfeedback',
2138 'navmethod' => QUIZ_NAVMETHOD_SEQ
2140 $quiz = $quizgenerator->create_instance($data);
2142 // Now generate the questions.
2143 $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
2144 $cat = $questiongenerator->create_question_category();
2145 for ($pageindex = 1; $pageindex <= 5; $pageindex++) {
2146 $question = $questiongenerator->create_question('truefalse', null, [
2147 'category' => $cat->id,
2148 'questiontext' => ['text' => "Question ($pageindex)"]
2150 quiz_add_quiz_question($question->id, $quiz, $pageindex);
2153 $quizobj = quiz_settings::create($quiz->id, $this->student->id);
2154 // Set grade to pass.
2155 $item = \grade_item::fetch(['courseid' => $this->course->id, 'itemtype' => 'mod',
2156 'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null]);
2157 $item->gradepass = 80;
2158 $item->update();
2159 return $quizobj;
2163 * Create question attempt
2165 * @param quiz_settings $quizobj
2166 * @param int|null $userid
2167 * @param bool|null $ispreview
2168 * @return quiz_attempt
2169 * @throws moodle_exception
2171 private function create_quiz_attempt_object(
2172 quiz_settings $quizobj,
2173 ?int $userid = null,
2174 ?bool $ispreview = false
2175 ): quiz_attempt {
2176 global $USER;
2178 $timenow = time();
2179 // Now, do one attempt.
2180 $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
2181 $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
2182 $attemptnumber = 1;
2183 if (!empty($USER->id)) {
2184 $attemptnumber = count(quiz_get_user_attempts($quizobj->get_quizid(), $USER->id)) + 1;
2186 $attempt = quiz_create_attempt($quizobj, $attemptnumber, false, $timenow, $ispreview, $userid ?? $this->student->id);
2187 quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow);
2188 quiz_attempt_save_started($quizobj, $quba, $attempt);
2189 $attemptobj = quiz_attempt::create($attempt->id);
2190 return $attemptobj;