MDL-66809 core_grades: Implement scale-based marking
[moodle.git] / grade / tests / grades_grader_gradingpanel_scale_external_store_test.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Unit tests for core_grades\component_gradeitems;
19  *
20  * @package   core_grades
21  * @category  test
22  * @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
23  * @license   http://www.gnu.org/copyleft/gpl.html GNU Public License
24  */
26 declare(strict_types = 1);
28 namespace core_grades\grades\grader\gradingpanel\scale\external;
30 use advanced_testcase;
31 use coding_exception;
32 use core_grades\component_gradeitem;
33 use external_api;
34 use mod_forum\local\entities\forum as forum_entity;
35 use moodle_exception;
36 use grade_grade;
37 use grade_item;
39 /**
40  * Unit tests for core_grades\component_gradeitems;
41  *
42  * @package   core_grades
43  * @category  test
44  * @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
45  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
46  */
47 class store_test extends advanced_testcase {
49     public static function setupBeforeClass(): void {
50         global $CFG;
51         require_once("{$CFG->libdir}/externallib.php");
52     }
54     /**
55      * Ensure that an execute with an invalid component is rejected.
56      */
57     public function test_execute_invalid_component(): void {
58         $this->resetAfterTest();
59         $user = $this->getDataGenerator()->create_user();
60         $this->setUser($user);
62         $this->expectException(coding_exception::class);
63         $this->expectExceptionMessage("The 'foo' item is not valid for the 'mod_invalid' component");
64         store::execute('mod_invalid', 1, 'foo', 2, 'formdata');
65     }
67     /**
68      * Ensure that an execute with an invalid itemname on a valid component is rejected.
69      */
70     public function test_execute_invalid_itemname(): void {
71         $this->resetAfterTest();
72         $user = $this->getDataGenerator()->create_user();
73         $this->setUser($user);
75         $this->expectException(coding_exception::class);
76         $this->expectExceptionMessage("The 'foo' item is not valid for the 'mod_forum' component");
77         store::execute('mod_forum', 1, 'foo', 2, 'formdata');
78     }
80     /**
81      * Ensure that an execute against a different grading method is rejected.
82      */
83     public function test_execute_incorrect_type(): void {
84         $this->resetAfterTest();
86         $forum = $this->get_forum_instance([
87             // Negative numbers mean a scale.
88             'grade_forum' => 5,
89         ]);
90         $course = $forum->get_course_record();
91         $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
92         $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
93         $this->setUser($teacher);
95         $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
97         $this->expectException(moodle_exception::class);
98         $this->expectExceptionMessage("not configured for grading with scales");
99         store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, 'formdata');
100     }
102     /**
103      * Ensure that an execute against a different grading method is rejected.
104      */
105     public function test_execute_disabled(): void {
106         $this->resetAfterTest();
108         $forum = $this->get_forum_instance();
109         $course = $forum->get_course_record();
110         $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
111         $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
112         $this->setUser($teacher);
114         $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
116         $this->expectException(moodle_exception::class);
117         $this->expectExceptionMessage("Grading is not enabled");
118         store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, 'formdata');
119     }
121     /**
122      * Ensure that an execute against the correct grading method returns the current state of the user.
123      */
124     public function test_execute_store_empty(): void {
125         [
126             'forum' => $forum,
127             'options' => $options,
128             'student' => $student,
129             'teacher' => $teacher,
130         ] = $this->get_test_data();
132         $this->setUser($teacher);
134         $formdata = [
135             'grade' => null,
136         ];
138         $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
140         $result = store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, http_build_query($formdata));
141         $result = external_api::clean_returnvalue(store::execute_returns(), $result);
143         // The result should still be empty.
144         $this->assertIsArray($result);
145         $this->assertArrayHasKey('templatename', $result);
147         $this->assertEquals('core_grades/grades/grader/gradingpanel/scale', $result['templatename']);
149         $this->assertArrayHasKey('grade', $result);
150         $this->assertIsArray($result['grade']);
151         $this->assertArrayHasKey('options', $result['grade']);
152         $this->assertCount(count($options), $result['grade']['options']);
153         rsort($options);
154         foreach ($options as $index => $option) {
155             $this->assertArrayHasKey($index, $result['grade']['options']);
157             $returnedoption = $result['grade']['options'][$index];
158             $this->assertArrayHasKey('value', $returnedoption);
159             $this->assertEquals(3 - $index, $returnedoption['value']);
161             $this->assertArrayHasKey('title', $returnedoption);
162             $this->assertEquals($option, $returnedoption['title']);
164             $this->assertArrayHasKey('selected', $returnedoption);
165             $this->assertFalse($returnedoption['selected']);
166         }
168         $this->assertIsInt($result['grade']['timecreated']);
169         $this->assertArrayHasKey('timemodified', $result['grade']);
170         $this->assertIsInt($result['grade']['timemodified']);
172         $this->assertArrayHasKey('warnings', $result);
173         $this->assertIsArray($result['warnings']);
174         $this->assertEmpty($result['warnings']);
176         // Compare against the grade stored in the database.
177         $storedgradeitem = grade_item::fetch([
178             'courseid' => $forum->get_course_id(),
179             'itemtype' => 'mod',
180             'itemmodule' => 'forum',
181             'iteminstance' => $forum->get_id(),
182             'itemnumber' => $gradeitem->get_grade_itemid(),
183         ]);
184         $storedgrade = grade_grade::fetch([
185             'userid' => $student->id,
186             'itemid' => $storedgradeitem->id,
187         ]);
189         $this->assertEmpty($storedgrade->rawgrade);
190     }
192     /**
193      * Ensure that an execute against the correct grading method returns the current state of the user.
194      */
195     public function test_execute_store_not_selected(): void {
196         [
197             'forum' => $forum,
198             'options' => $options,
199             'student' => $student,
200             'teacher' => $teacher,
201         ] = $this->get_test_data();
203         $this->setUser($teacher);
205         $formdata = [
206             'grade' => -1,
207         ];
209         $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
211         $result = store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, http_build_query($formdata));
212         $result = external_api::clean_returnvalue(store::execute_returns(), $result);
214         // The result should still be empty.
215         $this->assertIsArray($result);
216         $this->assertArrayHasKey('templatename', $result);
218         $this->assertEquals('core_grades/grades/grader/gradingpanel/scale', $result['templatename']);
220         $this->assertArrayHasKey('grade', $result);
221         $this->assertIsArray($result['grade']);
222         $this->assertArrayHasKey('options', $result['grade']);
223         $this->assertCount(count($options), $result['grade']['options']);
224         rsort($options);
225         foreach ($options as $index => $option) {
226             $this->assertArrayHasKey($index, $result['grade']['options']);
228             $returnedoption = $result['grade']['options'][$index];
229             $this->assertArrayHasKey('value', $returnedoption);
230             $this->assertEquals(3 - $index, $returnedoption['value']);
232             $this->assertArrayHasKey('title', $returnedoption);
233             $this->assertEquals($option, $returnedoption['title']);
235             $this->assertArrayHasKey('selected', $returnedoption);
236             $this->assertFalse($returnedoption['selected']);
237         }
239         $this->assertIsInt($result['grade']['timecreated']);
240         $this->assertArrayHasKey('timemodified', $result['grade']);
241         $this->assertIsInt($result['grade']['timemodified']);
243         $this->assertArrayHasKey('warnings', $result);
244         $this->assertIsArray($result['warnings']);
245         $this->assertEmpty($result['warnings']);
247         // Compare against the grade stored in the database.
248         $storedgradeitem = grade_item::fetch([
249             'courseid' => $forum->get_course_id(),
250             'itemtype' => 'mod',
251             'itemmodule' => 'forum',
252             'iteminstance' => $forum->get_id(),
253             'itemnumber' => $gradeitem->get_grade_itemid(),
254         ]);
255         $storedgrade = grade_grade::fetch([
256             'userid' => $student->id,
257             'itemid' => $storedgradeitem->id,
258         ]);
260         // No grade will have been saved.
261         $this->assertFalse($storedgrade);
262     }
264     /**
265      * Ensure that an execute against the correct grading method returns the current state of the user.
266      */
267     public function test_execute_store_graded(): void {
268         [
269             'scale' => $scale,
270             'forum' => $forum,
271             'options' => $options,
272             'student' => $student,
273             'teacher' => $teacher,
274         ] = $this->get_test_data();
276         $this->setUser($teacher);
278         $formdata = [
279             'grade' => 2,
280         ];
281         $formattedvalue = grade_floatval(unformat_float($formdata['grade']));
283         $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
285         $result = store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, http_build_query($formdata));
286         $result = external_api::clean_returnvalue(store::execute_returns(), $result);
288         // The result should still be empty.
289         $this->assertIsArray($result);
290         $this->assertArrayHasKey('templatename', $result);
292         $this->assertEquals('core_grades/grades/grader/gradingpanel/scale', $result['templatename']);
294         $this->assertArrayHasKey('grade', $result);
295         $this->assertIsArray($result['grade']);
296         $this->assertArrayHasKey('options', $result['grade']);
297         $this->assertCount(count($options), $result['grade']['options']);
298         rsort($options);
299         foreach ($options as $index => $option) {
300             $this->assertArrayHasKey($index, $result['grade']['options']);
302             $returnedoption = $result['grade']['options'][$index];
303             $this->assertArrayHasKey('value', $returnedoption);
304             $this->assertEquals(3 - $index, $returnedoption['value']);
306             $this->assertArrayHasKey('title', $returnedoption);
307             $this->assertEquals($option, $returnedoption['title']);
309             $this->assertArrayHasKey('selected', $returnedoption);
310         }
312         // The grade was 2, which relates to the middle option.
313         $this->assertFalse($result['grade']['options'][0]['selected']);
314         $this->assertTrue($result['grade']['options'][1]['selected']);
315         $this->assertFalse($result['grade']['options'][2]['selected']);
317         $this->assertIsInt($result['grade']['timecreated']);
318         $this->assertArrayHasKey('timemodified', $result['grade']);
319         $this->assertIsInt($result['grade']['timemodified']);
321         $this->assertArrayHasKey('warnings', $result);
322         $this->assertIsArray($result['warnings']);
323         $this->assertEmpty($result['warnings']);
325         // Compare against the grade stored in the database.
326         $storedgradeitem = grade_item::fetch([
327             'courseid' => $forum->get_course_id(),
328             'itemtype' => 'mod',
329             'itemmodule' => 'forum',
330             'iteminstance' => $forum->get_id(),
331             'itemnumber' => $gradeitem->get_grade_itemid(),
332         ]);
333         $storedgrade = grade_grade::fetch([
334             'userid' => $student->id,
335             'itemid' => $storedgradeitem->id,
336         ]);
338         $this->assertEquals($formattedvalue, $storedgrade->rawgrade);
339         $this->assertEquals($scale->id, $storedgrade->rawscaleid);
340     }
342     /**
343      * Ensure that an out-of-range value is rejected.
344      *
345      * @dataProvider execute_out_of_range_provider
346      * @param int $suppliedvalue The value that was submitted
347      */
348     public function test_execute_store_out_of_range(int $suppliedvalue): void {
349         [
350             'scale' => $scale,
351             'forum' => $forum,
352             'options' => $options,
353             'student' => $student,
354             'teacher' => $teacher,
355         ] = $this->get_test_data();
357         $this->setUser($teacher);
359         $formdata = [
360             'grade' => $suppliedvalue,
361         ];
363         $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
365         $this->expectException(moodle_exception::class);
366         $this->expectExceptionMessage("Invalid grade '{$suppliedvalue}' provided. Grades must be between 0 and 3.");
367         store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, http_build_query($formdata));
368     }
370     /**
371      * Data provider for out of range tests.
372      *
373      * @return array
374      */
375     public function execute_out_of_range_provider(): array {
376         return [
377             'above' => [
378                 'supplied' => 500,
379             ],
380             'above just' => [
381                 'supplied' => 4,
382             ],
383             'below' => [
384                 'supplied' => -100,
385             ],
386             '-10' => [
387                 'supplied' => -10,
388             ],
389         ];
390     }
393     /**
394      * Get a forum instance.
395      *
396      * @param array $config
397      * @return forum_entity
398      */
399     protected function get_forum_instance(array $config = []): forum_entity {
400         $this->resetAfterTest();
402         $datagenerator = $this->getDataGenerator();
403         $course = $datagenerator->create_course();
404         $forum = $datagenerator->create_module('forum', array_merge($config, ['course' => $course->id]));
406         $vaultfactory = \mod_forum\local\container::get_vault_factory();
407         $vault = $vaultfactory->get_forum_vault();
409         return $vault->get_from_id((int) $forum->id);
410     }
412     /**
413      * Get test data for scaled forums.
414      *
415      * @return array
416      */
417     protected function get_test_data(): array {
418         $this->resetAfterTest();
420         $options = [
421             'A',
422             'B',
423             'C'
424         ];
425         $scale = $this->getDataGenerator()->create_scale(['scale' => implode(',', $options)]);
427         $forum = $this->get_forum_instance([
428             // Negative numbers mean a scale.
429             'grade_forum' => -1 * $scale->id
430         ]);
431         $course = $forum->get_course_record();
432         $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
433         $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
435         return [
436             'forum' => $forum,
437             'scale' => $scale,
438             'options' => $options,
439             'student' => $student,
440             'teacher' => $teacher,
441         ];
442     }