2 // This file is part of Moodle - http://moodle.org/
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.
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/>.
18 * Quiz module external functions tests.
22 * @copyright 2016 Juan Leyva <juan@moodle.com>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
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
;
39 defined('MOODLE_INTERNAL') ||
die();
43 require_once($CFG->dirroot
. '/webservice/tests/helpers.php');
46 * Silly class to access mod_quiz_external internal methods.
49 * @copyright 2016 Juan Leyva <juan@moodle.com>
50 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
53 class testable_mod_quiz_external
extends mod_quiz_external
{
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);
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);
79 * Quiz module external functions tests
83 * @copyright 2016 Juan Leyva <juan@moodle.com>
84 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
87 class external_test
extends externallib_advanced_testcase
{
89 /** @var \stdClass course record. */
92 /** @var \stdClass activity record. */
95 /** @var \context_module context instance. */
101 /** @var \stdClass user record. */
104 /** @var \stdClass user record. */
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
{
118 $this->resetAfterTest();
119 $this->setAdminUser();
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
);
128 $this->student
= self
::getDataGenerator()->create_user();
129 $this->teacher
= self
::getDataGenerator()->create_user();
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
,
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;
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
);
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];
208 return [$quiz, $context, $quizobj];
214 * Test get quizzes by courses
216 public function test_mod_quiz_get_quizzes_by_courses() {
219 // Create additional course.
220 $course2 = self
::getDataGenerator()->create_course();
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;
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;
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
= [];
272 $quiz2->coursemodule
= $quiz2->cmid
;
273 $quiz2->introformat
= 1;
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
= [];
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() {
367 // Test invalid instance id.
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);
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();
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.
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.
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
,
557 $quizapi2 = $quizgenerator->create_instance([
558 'name' => 'Test Quiz API 2',
559 'course' => $this->course
->id
,
562 'marksimmediately' => 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
,
582 'itemmodule' => 'quiz',
583 'iteminstance' => $quizapi1->id
,
586 $item->gradepass
= 80;
589 $item = \grade_item
::fetch([
590 'courseid' => $this->course
->id
,
592 'itemmodule' => 'quiz',
593 'iteminstance' => $quizapi2->id
,
596 $item->gradepass
= 80;
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);
613 $this->assertFalse($result['hasgrade']);
614 $this->assertTrue(!isset($result['grade']));
616 // Start the attempt.
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');
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']);
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);
675 $this->assertFalse($result['hasgrade']);
676 $this->assertTrue(!isset($result['grade']));
678 // Start the attempt.
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() {
717 // Create a new quiz with attempts.
718 $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
719 $data = ['course' => $this->course
->id
,
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;
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
);
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);
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],
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],
771 $this->assertEquals($expected, $result);
773 // Now, finish the attempt.
774 $attemptobj = quiz_attempt
::create($attempt->id
);
775 $attemptobj->process_finish($timenow, false);
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],
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],
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.
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);
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],
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],
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);
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
);
850 public function test_start_attempt() {
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.
870 $quiz->timeclose
= 0;
871 $quiz->password
= 'abc';
872 $DB->update_record('quiz', $quiz);
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.
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.
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();
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() {
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
);
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
);
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);
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);
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
);
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.
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);
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();
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;
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() {
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
);
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.
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);
1152 $this->assertEquals(2, $found);
1157 * Test get_attempt_data with blocked questions.
1160 public function test_get_attempt_data_with_blocked_questions() {
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() {
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']);
1262 public function test_save_attempt() {
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);
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);
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() {
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);
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);
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,
1408 'filename' => 'faketxt.txt',
1410 $fs = get_file_storage();
1411 $fs->create_file_from_string($filerecordinline, 'fake txt contents 1.');
1414 $prefix = $quba->get_field_prefix(3);
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);
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
);
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);
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']);
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);
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() {
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.
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.
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);
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() {
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']);
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() {
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']];
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.
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() {
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() {
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() {
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
,
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() {
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);
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' => [],
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() {
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
,
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;
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);
1918 'isfinished' => false,
1919 'preventnewattemptreasons' => [],
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
);
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);
1996 'questiontypes' => ['essay', 'numerical', 'shortanswer', 'truefalse'],
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, []));
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.
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.
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());
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');
2135 'course' => $this->course
->id
,
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;
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
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
);
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
);