1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * @package    core_grades
19 * @category   phpunit
20 * @copyright  nicolas@moodle.com
21 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22 */
23
24defined('MOODLE_INTERNAL') || die();
25
26require_once(__DIR__.'/fixtures/lib.php');
27
28
29class core_grade_category_testcase extends grade_base_testcase {
30
31    public function test_grade_category() {
32        $this->sub_test_grade_category_construct();
33        $this->sub_test_grade_category_build_path();
34        $this->sub_test_grade_category_fetch();
35        $this->sub_test_grade_category_fetch_all();
36        $this->sub_test_grade_category_update();
37        $this->sub_test_grade_category_delete();
38        $this->sub_test_grade_category_insert();
39        $this->sub_test_grade_category_qualifies_for_regrading();
40        $this->sub_test_grade_category_force_regrading();
41        $this->sub_test_grade_category_aggregate_grades();
42        $this->sub_test_grade_category_apply_limit_rules();
43        $this->sub_test_grade_category_is_aggregationcoef_used();
44        $this->sub_test_grade_category_aggregation_uses_aggregationcoef();
45        $this->sub_test_grade_category_fetch_course_tree();
46        $this->sub_test_grade_category_get_children();
47        $this->sub_test_grade_category_load_grade_item();
48        $this->sub_test_grade_category_get_grade_item();
49        $this->sub_test_grade_category_load_parent_category();
50        $this->sub_test_grade_category_get_parent_category();
51        $this->sub_test_grade_category_get_name();
52        $this->sub_test_grade_category_generate_grades_aggregationweight();
53        $this->sub_test_grade_category_set_parent();
54        $this->sub_test_grade_category_get_final();
55        $this->sub_test_grade_category_get_sortorder();
56        $this->sub_test_grade_category_set_sortorder();
57        $this->sub_test_grade_category_is_editable();
58        $this->sub_test_grade_category_move_after_sortorder();
59        $this->sub_test_grade_category_is_course_category();
60        $this->sub_test_grade_category_fetch_course_category();
61        $this->sub_test_grade_category_is_locked();
62        $this->sub_test_grade_category_set_locked();
63        $this->sub_test_grade_category_is_hidden();
64        $this->sub_test_grade_category_set_hidden();
65        $this->sub_test_grade_category_can_control_visibility();
66        $this->sub_test_grade_category_total_visibility();
67
68        // This won't work until MDL-11837 is complete.
69        // $this->sub_test_grade_category_generate_grades();
70
71        // Do this last as adding a second course category messes up the data.
72        $this->sub_test_grade_category_insert_course_category();
73        $this->sub_test_grade_category_is_extracredit_used();
74        $this->sub_test_grade_category_aggregation_uses_extracredit();
75    }
76
77    // Adds 3 new grade categories at various depths.
78    protected function sub_test_grade_category_construct() {
79        $course_category = grade_category::fetch_course_category($this->courseid);
80
81        $params = new stdClass();
82
83        $params->courseid = $this->courseid;
84        $params->fullname = 'unittestcategory4';
85
86        $grade_category = new grade_category($params, false);
87        $grade_category->insert();
88        $this->grade_categories[] = $grade_category;
89
90        $this->assertEquals($params->courseid, $grade_category->courseid);
91        $this->assertEquals($params->fullname, $grade_category->fullname);
92        $this->assertEquals(2, $grade_category->depth);
93        $this->assertEquals("/$course_category->id/$grade_category->id/", $grade_category->path);
94        $parentpath = $grade_category->path;
95
96        // Test a child category.
97        $params->parent = $grade_category->id;
98        $params->fullname = 'unittestcategory5';
99        $grade_category = new grade_category($params, false);
100        $grade_category->insert();
101        $this->grade_categories[] = $grade_category;
102
103        $this->assertEquals(3, $grade_category->depth);
104        $this->assertEquals($parentpath.$grade_category->id."/", $grade_category->path);
105        $parentpath = $grade_category->path;
106
107        // Test a third depth category.
108        $params->parent = $grade_category->id;
109        $params->fullname = 'unittestcategory6';
110        $grade_category = new grade_category($params, false);
111        $grade_category->insert();
112        $this->grade_categories[50] = $grade_category;// Going to delete this one later hence the special index.
113
114        $this->assertEquals(4, $grade_category->depth);
115        $this->assertEquals($parentpath.$grade_category->id."/", $grade_category->path);
116    }
117
118    protected function sub_test_grade_category_build_path() {
119        $grade_category = new grade_category($this->grade_categories[1]);
120        $this->assertTrue(method_exists($grade_category, 'build_path'));
121        $path = grade_category::build_path($grade_category);
122        $this->assertEquals($grade_category->path, $path);
123    }
124
125    protected function sub_test_grade_category_fetch() {
126        $grade_category = new grade_category();
127        $this->assertTrue(method_exists($grade_category, 'fetch'));
128
129        $grade_category = grade_category::fetch(array('id'=>$this->grade_categories[0]->id));
130        $this->assertEquals($this->grade_categories[0]->id, $grade_category->id);
131        $this->assertEquals($this->grade_categories[0]->fullname, $grade_category->fullname);
132    }
133
134    protected function sub_test_grade_category_fetch_all() {
135        $grade_category = new grade_category();
136        $this->assertTrue(method_exists($grade_category, 'fetch_all'));
137
138        $grade_categories = grade_category::fetch_all(array('courseid'=>$this->courseid));
139        $this->assertEquals(count($this->grade_categories), count($grade_categories)-1);
140    }
141
142    protected function sub_test_grade_category_update() {
143        global $DB;
144        $grade_category = new grade_category($this->grade_categories[0]);
145        $this->assertTrue(method_exists($grade_category, 'update'));
146
147        $grade_category->fullname = 'Updated info for this unittest grade_category';
148        $grade_category->path = null; // Path must be recalculated if missing.
149        $grade_category->depth = null;
150        $grade_category->aggregation = GRADE_AGGREGATE_MAX; // Should force regrading.
151
152        $grade_item = $grade_category->get_grade_item();
153        $this->assertEquals(0, $grade_item->needsupdate);
154
155        $this->assertTrue($grade_category->update());
156
157        $fullname = $DB->get_field('grade_categories', 'fullname', array('id' => $this->grade_categories[0]->id));
158        $this->assertEquals($grade_category->fullname, $fullname);
159
160        $path = $DB->get_field('grade_categories', 'path', array('id' => $this->grade_categories[0]->id));
161        $this->assertEquals($grade_category->path, $path);
162
163        $depth = $DB->get_field('grade_categories', 'depth', array('id' => $this->grade_categories[0]->id));
164        $this->assertEquals($grade_category->depth, $depth);
165
166        $grade_item = $grade_category->get_grade_item();
167        $this->assertEquals(1, $grade_item->needsupdate);
168    }
169
170    protected function sub_test_grade_category_delete() {
171        global $DB;
172
173        $grade_category = new grade_category($this->grade_categories[50]);
174        $this->assertTrue(method_exists($grade_category, 'delete'));
175
176        $this->assertTrue($grade_category->delete());
177        $this->assertFalse($DB->get_record('grade_categories', array('id' => $grade_category->id)));
178    }
179
180    protected function sub_test_grade_category_insert() {
181        $course_category = grade_category::fetch_course_category($this->courseid);
182
183        $grade_category = new grade_category();
184        $this->assertTrue(method_exists($grade_category, 'insert'));
185
186        $grade_category->fullname    = 'unittestcategory4';
187        $grade_category->courseid    = $this->courseid;
188        $grade_category->aggregation = GRADE_AGGREGATE_MEAN;
189        $grade_category->aggregateonlygraded = 1;
190        $grade_category->keephigh    = 100;
191        $grade_category->droplow     = 10;
192        $grade_category->hidden      = 0;
193        $grade_category->parent      = $this->grade_categories[1]->id; // sub_test_grade_category_delete() removed the category at 0.
194
195        $grade_category->insert();
196
197        $this->assertEquals('/'.$course_category->id.'/'.$this->grade_categories[1]->parent.'/'.$this->grade_categories[1]->id.'/'.$grade_category->id.'/', $grade_category->path);
198        $this->assertEquals(4, $grade_category->depth);
199
200        $last_grade_category = end($this->grade_categories);
201
202        $this->assertFalse(empty($grade_category->grade_item));
203        $this->assertEquals($grade_category->id, $grade_category->grade_item->iteminstance);
204        $this->assertEquals('category', $grade_category->grade_item->itemtype);
205
206        $this->assertEquals($grade_category->id, $last_grade_category->id + 1);
207        $this->assertFalse(empty($grade_category->timecreated));
208        $this->assertFalse(empty($grade_category->timemodified));
209    }
210
211    protected function sub_test_grade_category_qualifies_for_regrading() {
212        $grade_category = new grade_category($this->grade_categories[1]);
213        $this->assertTrue(method_exists($grade_category, 'qualifies_for_regrading'));
214        $this->assertFalse($grade_category->qualifies_for_regrading());
215
216        $grade_category->aggregation = GRADE_AGGREGATE_MAX;
217        $this->assertTrue($grade_category->qualifies_for_regrading());
218
219        $grade_category = new grade_category($this->grade_categories[1]);
220        $grade_category->droplow = 99;
221        $this->assertTrue($grade_category->qualifies_for_regrading());
222
223        $grade_category = new grade_category($this->grade_categories[1]);
224        $grade_category->keephigh = 99;
225        $this->assertTrue($grade_category->qualifies_for_regrading());
226    }
227
228    protected function sub_test_grade_category_force_regrading() {
229        $grade_category = new grade_category($this->grade_categories[1]);
230        $this->assertTrue(method_exists($grade_category, 'force_regrading'));
231
232        $grade_category->load_grade_item();
233        $this->assertEquals(0, $grade_category->grade_item->needsupdate);
234
235        $grade_category->force_regrading();
236
237        $grade_category->grade_item = null;
238        $grade_category->load_grade_item();
239
240        $this->assertEquals(1, $grade_category->grade_item->needsupdate);
241    }
242
243    /**
244     * Tests the setting of the grade_grades aggregationweight column.
245     * Currently, this is only a regression test for MDL-51715.
246     * This must be run before sub_test_grade_category_set_parent(), which alters
247     * the fixture.
248     */
249    protected function sub_test_grade_category_generate_grades_aggregationweight() {
250        global $DB;
251
252        // Start of regression test for MDL-51715.
253        // grade_categories [1] and [2] are child categories of [0]
254        // Ensure that grades have been generated with fixture data.
255        $childcat1 = new grade_category($this->grade_categories[1]);
256        $childcat1itemid = $childcat1->load_grade_item()->id;
257        $childcat1->generate_grades();
258        $childcat2 = new grade_category($this->grade_categories[2]);
259        $childcat2itemid = $childcat2->load_grade_item()->id;
260        $childcat2->generate_grades();
261        $parentcat = new grade_category($this->grade_categories[0]);
262        $parentcat->generate_grades();
263
264        // Drop low and and re-generate to produce 'dropped' aggregation status.
265        $parentcat->droplow = 1;
266        $parentcat->generate_grades();
267
268        $this->assertTrue($DB->record_exists_select(
269                                     'grade_grades',
270                                     "aggregationstatus='dropped' and itemid in (?,?)",
271                                     array($childcat1itemid, $childcat2itemid)));
272        $this->assertFalse($DB->record_exists_select(
273                                     'grade_grades',
274                                     "aggregationstatus='dropped' and aggregationweight > 0.00"),
275                           "aggregationweight should be 0.00 if aggregationstatus=='dropped'");
276
277        // Reset grade data to be consistent with fixture data.
278        $parentcat->droplow = 0;
279        $parentcat->generate_grades();
280
281        // Blank out the final grade for one of the child categories and re-generate
282        // to produce 'novalue' aggregationstatus.  Direct DB update is testing shortcut.
283        $DB->set_field('grade_grades', 'finalgrade', null, array('itemid'=>$childcat1itemid));
284        $parentcat->generate_grades();
285
286        $this->assertFalse($DB->record_exists_select(
287                                     'grade_grades',
288                                     "aggregationstatus='dropped' and itemid in (?,?)",
289                                     array($childcat1itemid, $childcat2itemid)));
290        $this->assertTrue($DB->record_exists_select(
291                                     'grade_grades',
292                                     "aggregationstatus='novalue' and itemid = ?",
293                                     array($childcat1itemid)));
294        $this->assertFalse($DB->record_exists_select(
295                                     'grade_grades',
296                                     "aggregationstatus='novalue' and aggregationweight > 0.00"),
297                           "aggregationweight should be 0.00 if aggregationstatus=='novalue'");
298
299        // Re-generate to be consistent with fixture data.
300        $childcat1->generate_grades();
301        $parentcat->generate_grades();
302        // End of regression test for MDL-51715.
303    }
304
305    /**
306     * Tests the calculation of grades using the various aggregation methods with and without hidden grades
307     * This will not work entirely until MDL-11837 is done
308     */
309    protected function sub_test_grade_category_generate_grades() {
310        global $DB;
311
312        // Inserting some special grade items to make testing the final grade calculation easier.
313        $params = new stdClass();
314        $params->courseid = $this->courseid;
315        $params->fullname = 'unittestgradecalccategory';
316        $params->aggregation = GRADE_AGGREGATE_MEAN;
317        $params->aggregateonlygraded = 0;
318        $grade_category = new grade_category($params, false);
319        $grade_category->insert();
320
321        $this->assertTrue(method_exists($grade_category, 'generate_grades'));
322
323        $grade_category->load_grade_item();
324        $cgi = $grade_category->get_grade_item();
325        $cgi->grademin = 0;
326        $cgi->grademax = 20; // 3 grade items out of 10 but category is out of 20 to force scaling to occur.
327        $cgi->update();
328
329        // 3 grade items each with a maximum grade of 10.
330        $grade_items = array();
331        for ($i=0; $i<3; $i++) {
332            $grade_items[$i] = new grade_item();
333            $grade_items[$i]->courseid = $this->courseid;
334            $grade_items[$i]->categoryid = $grade_category->id;
335            $grade_items[$i]->itemname = 'manual grade_item '.$i;
336            $grade_items[$i]->itemtype = 'manual';
337            $grade_items[$i]->itemnumber = 0;
338            $grade_items[$i]->needsupdate = false;
339            $grade_items[$i]->gradetype = GRADE_TYPE_VALUE;
340            $grade_items[$i]->grademin = 0;
341            $grade_items[$i]->grademax = 10;
342            $grade_items[$i]->iteminfo = 'Manual grade item used for unit testing';
343            $grade_items[$i]->timecreated = time();
344            $grade_items[$i]->timemodified = time();
345
346            // Used as the weight by weighted mean and as extra credit by mean with extra credit.
347            // Will be 0, 1 and 2.
348            $grade_items[$i]->aggregationcoef = $i;
349
350            $grade_items[$i]->insert();
351        }
352
353        // A grade for each grade item.
354        $grade_grades = array();
355        for ($i=0; $i<3; $i++) {
356            $grade_grades[$i] = new grade_grade();
357            $grade_grades[$i]->itemid = $grade_items[$i]->id;
358            $grade_grades[$i]->userid = $this->userid;
359            $grade_grades[$i]->rawgrade = ($i+1)*2; // Produce grade grades of 2, 4 and 6.
360            $grade_grades[$i]->finalgrade = ($i+1)*2;
361            $grade_grades[$i]->timecreated = time();
362            $grade_grades[$i]->timemodified = time();
363            $grade_grades[$i]->information = '1 of 2 grade_grades';
364            $grade_grades[$i]->informationformat = FORMAT_PLAIN;
365            $grade_grades[$i]->feedback = 'Good, but not good enough..';
366            $grade_grades[$i]->feedbackformat = FORMAT_PLAIN;
367
368            $grade_grades[$i]->insert();
369        }
370
371        // 3 grade items with 1 grade_grade each.
372        // grade grades have the values 2, 4 and 6.
373
374        // First correct answer is the aggregate with all 3 grades.
375        // Second correct answer is with the first grade (value 2) hidden.
376
377        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_MEDIAN, 'GRADE_AGGREGATE_MEDIAN', 8, 8);
378        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_MAX, 'GRADE_AGGREGATE_MAX', 12, 12);
379        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_MODE, 'GRADE_AGGREGATE_MODE', 12, 12);
380
381        // Weighted mean. note grade totals are rounded to an int to prevent rounding discrepancies. correct final grade isnt actually exactly 10
382        // 3 items with grades 2, 4 and 6 with weights 0, 1 and 2 and all out of 10. then doubled to be out of 20.
383        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_WEIGHTED_MEAN, 'GRADE_AGGREGATE_WEIGHTED_MEAN', 10, 10);
384
385        // Simple weighted mean.
386        // 3 items with grades 2, 4 and 6 equally weighted and all out of 10. then doubled to be out of 20.
387        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_WEIGHTED_MEAN2, 'GRADE_AGGREGATE_WEIGHTED_MEAN2', 8, 10);
388
389        // Mean of grades with extra credit.
390        // 3 items with grades 2, 4 and 6 with extra credit 0, 1 and 2 equally weighted and all out of 10. then doubled to be out of 20.
391        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_EXTRACREDIT_MEAN, 'GRADE_AGGREGATE_EXTRACREDIT_MEAN', 10, 13);
392
393        // Aggregation tests the are affected by a hidden grade currently dont work as we dont store the altered grade in the database
394        // instead an in memory recalculation is done. This should be remedied by MDL-11837.
395
396        // Fails with 1 grade hidden. still reports 8 as being correct.
397        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_MEAN, 'GRADE_AGGREGATE_MEAN', 8, 10);
398
399        // Fails with 1 grade hidden. still reports 4 as being correct.
400        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_MIN, 'GRADE_AGGREGATE_MIN', 4, 8);
401
402        // Fails with 1 grade hidden. still reports 12 as being correct.
403        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_SUM, 'GRADE_AGGREGATE_SUM', 12, 10);
404    }
405
406    /**
407     * Test grade category aggregation using the supplied grade objects and aggregation method
408     * @param grade_category $grade_category the category to be tested
409     * @param array $grade_items array of instance of grade_item
410     * @param array $grade_grades array of instances of grade_grade
411     * @param int $aggmethod the aggregation method to apply ie GRADE_AGGREGATE_MEAN
412     * @param string $aggmethodname the name of the aggregation method to apply. Used to display any test failure messages
413     * @param int $correct1 the correct final grade for the category with NO items hidden
414     * @param int $correct2 the correct final grade for the category with the grade at $grade_grades[0] hidden
415     * @return void
416     */
417    protected function helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, $aggmethod, $aggmethodname, $correct1, $correct2) {
418        $grade_category->aggregation = $aggmethod;
419        $grade_category->update();
420
421        // Check grade_item isnt hidden from a previous test.
422        $grade_items[0]->set_hidden(0, true);
423        $this->helper_test_grade_aggregation_result($grade_category, $correct1, 'Testing aggregation method('.$aggmethodname.') with no items hidden %s');
424
425        // Hide the grade item with grade of 2.
426        $grade_items[0]->set_hidden(1, true);
427        $this->helper_test_grade_aggregation_result($grade_category, $correct2, 'Testing aggregation method('.$aggmethodname.') with 1 item hidden %s');
428    }
429
430    /**
431     * Verify the value of the category grade item for $this->userid
432     * @param grade_category $grade_category the category to be tested
433     * @param int $correctgrade the expected grade
434     * @param string msg The message that should be displayed if the correct grade is not found
435     * @return void
436     */
437    protected function helper_test_grade_aggregation_result($grade_category, $correctgrade, $msg) {
438        global $DB;
439
440        $category_grade_item = $grade_category->get_grade_item();
441
442        // This creates all the grade_grades we need.
443        grade_regrade_final_grades($this->courseid);
444
445        $grade = $DB->get_record('grade_grades', array('itemid'=>$category_grade_item->id, 'userid'=>$this->userid));
446        $this->assertWithinMargin($grade->rawgrade, $grade->rawgrademin, $grade->rawgrademax);
447        $this->assertEquals(intval($correctgrade), intval($grade->finalgrade), $msg);
448
449        /*
450         * TODO this doesnt work as the grade_grades created by $grade_category->generate_grades(); dont
451         * observe the category's max grade
452        // delete the grade_grades for the category itself and check they get recreated correctly.
453        $DB->delete_records('grade_grades', array('itemid'=>$category_grade_item->id));
454        $grade_category->generate_grades();
455
456        $grade = $DB->get_record('grade_grades', array('itemid'=>$category_grade_item->id, 'userid'=>$this->userid));
457        $this->assertWithinMargin($grade->rawgrade, $grade->rawgrademin, $grade->rawgrademax);
458        $this->assertEquals(intval($correctgrade), intval($grade->finalgrade), $msg);
459         *
460         */
461    }
462
463    protected function sub_test_grade_category_aggregate_grades() {
464        $category = new grade_category($this->grade_categories[0]);
465        $this->assertTrue(method_exists($category, 'aggregate_grades'));
466        // Tested more fully via test_grade_category_generate_grades().
467    }
468
469    protected function sub_test_grade_category_apply_limit_rules() {
470        $items[$this->grade_items[0]->id] = new grade_item($this->grade_items[0], false);
471        $items[$this->grade_items[1]->id] = new grade_item($this->grade_items[1], false);
472        $items[$this->grade_items[2]->id] = new grade_item($this->grade_items[2], false);
473        $items[$this->grade_items[4]->id] = new grade_item($this->grade_items[4], false);
474
475        // Test excluding the lowest 2 out of 4 grades from aggregation with no 0 grades.
476        $category = new grade_category();
477        $category->droplow = 2;
478        $grades = array($this->grade_items[0]->id=>5.374,
479                        $this->grade_items[1]->id=>9.4743,
480                        $this->grade_items[2]->id=>2.5474,
481                        $this->grade_items[4]->id=>7.3754);
482        $category->apply_limit_rules($grades, $items);
483        $this->assertEquals(count($grades), 2);
484        $this->assertEquals($grades[$this->grade_items[1]->id], 9.4743);
485        $this->assertEquals($grades[$this->grade_items[4]->id], 7.3754);
486
487        // Test aggregating only the highest 1 out of 4 grades.
488        $category = new grade_category();
489        $category->keephigh = 1;
490        $category->droplow = 0;
491        $grades = array($this->grade_items[0]->id=>5.374,
492                        $this->grade_items[1]->id=>9.4743,
493                        $this->grade_items[2]->id=>2.5474,
494                        $this->grade_items[4]->id=>7.3754);
495        $category->apply_limit_rules($grades, $items);
496        $this->assertEquals(count($grades), 1);
497        $grade = reset($grades);
498        $this->assertEquals(9.4743, $grade);
499
500        // Test excluding the lowest 2 out of 4 grades from aggregation with no 0 grades.
501        // An extra credit grade item should be kept even if droplow means it would otherwise be excluded.
502        $category = new grade_category();
503        $category->droplow     = 2;
504        $category->aggregation = GRADE_AGGREGATE_SUM;
505        $items[$this->grade_items[2]->id]->aggregationcoef = 1; // Mark grade item 2 as "extra credit".
506        $grades = array($this->grade_items[0]->id=>5.374,
507                        $this->grade_items[1]->id=>9.4743,
508                        $this->grade_items[2]->id=>2.5474,
509                        $this->grade_items[4]->id=>7.3754);
510        $category->apply_limit_rules($grades, $items);
511        $this->assertEquals(count($grades), 2);
512        $this->assertEquals($grades[$this->grade_items[1]->id], 9.4743);
513        $this->assertEquals($grades[$this->grade_items[2]->id], 2.5474);
514
515        // Test only aggregating the highest 1 out of 4 grades.
516        // An extra credit grade item is retained in addition to the highest grade.
517        $category = new grade_category();
518        $category->keephigh = 1;
519        $category->droplow = 0;
520        $category->aggregation = GRADE_AGGREGATE_SUM;
521        $items[$this->grade_items[2]->id]->aggregationcoef = 1; // Mark grade item 2 as "extra credit".
522        $grades = array($this->grade_items[0]->id=>5.374,
523                        $this->grade_items[1]->id=>9.4743,
524                        $this->grade_items[2]->id=>2.5474,
525                        $this->grade_items[4]->id=>7.3754);
526        $category->apply_limit_rules($grades, $items);
527        $this->assertEquals(count($grades), 2);
528        $this->assertEquals($grades[$this->grade_items[1]->id], 9.4743);
529        $this->assertEquals($grades[$this->grade_items[2]->id], 2.5474);
530
531        // Test excluding the lowest 1 out of 4 grades from aggregation with two 0 grades.
532        $items[$this->grade_items[2]->id]->aggregationcoef = 0; // Undo marking grade item 2 as "extra credit".
533        $category = new grade_category();
534        $category->droplow     = 1;
535        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2; // Simple weighted mean.
536        $grades = array($this->grade_items[0]->id=>0, // 0 out of 110. Should be excluded from aggregation.
537                        $this->grade_items[1]->id=>5, // 5 out of 100.
538                        $this->grade_items[2]->id=>2, // 0 out of 6.
539                        $this->grade_items[4]->id=>0); // 0 out of 100.
540        $category->apply_limit_rules($grades, $items);
541        $this->assertEquals(count($grades), 3);
542        $this->assertEquals($grades[$this->grade_items[1]->id], 5);
543        $this->assertEquals($grades[$this->grade_items[2]->id], 2);
544        $this->assertEquals($grades[$this->grade_items[4]->id], 0);
545
546        // Test excluding the lowest 2 out of 4 grades from aggregation with three 0 grades.
547        $category = new grade_category();
548        $category->droplow     = 2;
549        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2; // Simple weighted mean.
550        $grades = array($this->grade_items[0]->id=>0, // 0 out of 110. Should be excluded from aggregation.
551                        $this->grade_items[1]->id=>5, // 5 out of 100.
552                        $this->grade_items[2]->id=>0, // 0 out of 6.
553                        $this->grade_items[4]->id=>0); // 0 out of 100. Should be excluded from aggregation.
554        $category->apply_limit_rules($grades, $items);
555        $this->assertEquals(count($grades), 2);
556        $this->assertEquals($grades[$this->grade_items[1]->id], 5);
557        $this->assertEquals($grades[$this->grade_items[2]->id], 0);
558
559        // Test excluding the lowest 5 out of 4 grades from aggregation.
560        // Just to check we handle this sensibly.
561        $category = new grade_category();
562        $category->droplow     = 5;
563        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2; // Simple weighted mean.
564        $grades = array($this->grade_items[0]->id=>0, // 0 out of 110. Should be excluded from aggregation.
565                        $this->grade_items[1]->id=>5, // 5 out of 100.
566                        $this->grade_items[2]->id=>6, // 6 out of 6.
567                        $this->grade_items[4]->id=>1);// 1 out of 100. Should be excluded from aggregation.
568        $category->apply_limit_rules($grades, $items);
569        $this->assertEquals(count($grades), 0);
570
571        // Test excluding the lowest 4 out of 4 grades from aggregation with one marked as extra credit.
572        $category = new grade_category();
573        $category->droplow     = 4;
574        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2; // Simple weighted mean.
575        $items[$this->grade_items[2]->id]->aggregationcoef = 1; // Mark grade item 2 as "extra credit".
576        $grades = array($this->grade_items[0]->id=>0, // 0 out of 110. Should be excluded from aggregation.
577                        $this->grade_items[1]->id=>5, // 5 out of 100. Should be excluded from aggregation.
578                        $this->grade_items[2]->id=>6, // 6 out of 6. Extra credit. Should be retained.
579                        $this->grade_items[4]->id=>1);// 1 out of 100. Should be excluded from aggregation.
580        $category->apply_limit_rules($grades, $items);
581        $this->assertEquals(count($grades), 1);
582        $this->assertEquals($grades[$this->grade_items[2]->id], 6);
583
584        // MDL-35667 - There was an infinite loop if several items had the same grade and at least one was extra credit.
585        $category = new grade_category();
586        $category->droplow     = 1;
587        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2; // Simple weighted mean.
588        $items[$this->grade_items[1]->id]->aggregationcoef = 1; // Mark grade item 1 as "extra credit".
589        $grades = array($this->grade_items[0]->id=>1, // 1 out of 110. Should be excluded from aggregation.
590                        $this->grade_items[1]->id=>1, // 1 out of 100. Extra credit. Should be retained.
591                        $this->grade_items[2]->id=>1, // 1 out of 6. Should be retained.
592                        $this->grade_items[4]->id=>1);// 1 out of 100. Should be retained.
593        $category->apply_limit_rules($grades, $items);
594        $this->assertEquals(count($grades), 3);
595        $this->assertEquals($grades[$this->grade_items[1]->id], 1);
596        $this->assertEquals($grades[$this->grade_items[2]->id], 1);
597        $this->assertEquals($grades[$this->grade_items[4]->id], 1);
598
599    }
600
601    protected function sub_test_grade_category_is_aggregationcoef_used() {
602        $category = new grade_category();
603        // Following use aggregationcoef.
604        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN;
605        $this->assertTrue($category->is_aggregationcoef_used());
606        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2;
607        $this->assertTrue($category->is_aggregationcoef_used());
608        $category->aggregation = GRADE_AGGREGATE_EXTRACREDIT_MEAN;
609        $this->assertTrue($category->is_aggregationcoef_used());
610        $category->aggregation = GRADE_AGGREGATE_SUM;
611        $this->assertTrue($category->is_aggregationcoef_used());
612
613        // Following don't use aggregationcoef.
614        $category->aggregation = GRADE_AGGREGATE_MAX;
615        $this->assertFalse($category->is_aggregationcoef_used());
616        $category->aggregation = GRADE_AGGREGATE_MEAN;
617        $this->assertFalse($category->is_aggregationcoef_used());
618        $category->aggregation = GRADE_AGGREGATE_MEDIAN;
619        $this->assertFalse($category->is_aggregationcoef_used());
620        $category->aggregation = GRADE_AGGREGATE_MIN;
621        $this->assertFalse($category->is_aggregationcoef_used());
622        $category->aggregation = GRADE_AGGREGATE_MODE;
623        $this->assertFalse($category->is_aggregationcoef_used());
624    }
625
626    protected function sub_test_grade_category_aggregation_uses_aggregationcoef() {
627
628        $this->assertTrue(grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_WEIGHTED_MEAN));
629        $this->assertTrue(grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_WEIGHTED_MEAN2));
630        $this->assertTrue(grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_EXTRACREDIT_MEAN));
631        $this->assertTrue(grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_SUM));
632
633        $this->assertFalse(grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_MAX));
634        $this->assertFalse(grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_MEAN));
635        $this->assertFalse(grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_MEDIAN));
636        $this->assertFalse(grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_MIN));
637        $this->assertFalse(grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_MODE));
638    }
639
640    protected function sub_test_grade_category_fetch_course_tree() {
641        $category = new grade_category();
642        $this->assertTrue(method_exists($category, 'fetch_course_tree'));
643        // TODO: add some tests.
644    }
645
646    protected function sub_test_grade_category_get_children() {
647        $course_category = grade_category::fetch_course_category($this->courseid);
648
649        $category = new grade_category($this->grade_categories[0]);
650        $this->assertTrue(method_exists($category, 'get_children'));
651
652        $children_array = $category->get_children(0);
653
654        $this->assertTrue(is_array($children_array));
655        $this->assertFalse(empty($children_array[2]));
656        $this->assertFalse(empty($children_array[2]['object']));
657        $this->assertFalse(empty($children_array[2]['children']));
658        $this->assertEquals($this->grade_categories[1]->id, $children_array[2]['object']->id);
659        $this->assertEquals($this->grade_categories[2]->id, $children_array[5]['object']->id);
660        $this->assertEquals($this->grade_items[0]->id, $children_array[2]['children'][3]['object']->id);
661        $this->assertEquals($this->grade_items[1]->id, $children_array[2]['children'][4]['object']->id);
662        $this->assertEquals($this->grade_items[2]->id, $children_array[5]['children'][6]['object']->id);
663    }
664
665    protected function sub_test_grade_category_load_grade_item() {
666        $category = new grade_category($this->grade_categories[0]);
667        $this->assertTrue(method_exists($category, 'load_grade_item'));
668        $this->assertEquals(null, $category->grade_item);
669        $category->load_grade_item();
670        $this->assertEquals($this->grade_items[3]->id, $category->grade_item->id);
671    }
672
673    protected function sub_test_grade_category_get_grade_item() {
674        $category = new grade_category($this->grade_categories[0]);
675        $this->assertTrue(method_exists($category, 'get_grade_item'));
676        $grade_item = $category->get_grade_item();
677        $this->assertEquals($this->grade_items[3]->id, $grade_item->id);
678    }
679
680    protected function sub_test_grade_category_load_parent_category() {
681        $category = new grade_category($this->grade_categories[1]);
682        $this->assertTrue(method_exists($category, 'load_parent_category'));
683        $this->assertEquals(null, $category->parent_category);
684        $category->load_parent_category();
685        $this->assertEquals($this->grade_categories[0]->id, $category->parent_category->id);
686    }
687
688    protected function sub_test_grade_category_get_parent_category() {
689        $category = new grade_category($this->grade_categories[1]);
690        $this->assertTrue(method_exists($category, 'get_parent_category'));
691        $parent_category = $category->get_parent_category();
692        $this->assertEquals($this->grade_categories[0]->id, $parent_category->id);
693    }
694
695    protected function sub_test_grade_category_get_name() {
696        $category = new grade_category($this->grade_categories[0]);
697        $this->assertTrue(method_exists($category, 'get_name'));
698        $this->assertEquals($this->grade_categories[0]->fullname, $category->get_name());
699    }
700
701    protected function sub_test_grade_category_set_parent() {
702        $category = new grade_category($this->grade_categories[1]);
703        $this->assertTrue(method_exists($category, 'set_parent'));
704        // TODO: implement detailed tests.
705
706        $course_category = grade_category::fetch_course_category($this->courseid);
707        $this->assertTrue($category->set_parent($course_category->id));
708        $this->assertEquals($course_category->id, $category->parent);
709    }
710
711    protected function sub_test_grade_category_get_final() {
712        $category = new grade_category($this->grade_categories[0]);
713        $this->assertTrue(method_exists($category, 'get_final'));
714        $category->load_grade_item();
715        $this->assertEquals($category->get_final(), $category->grade_item->get_final());
716    }
717
718    protected function sub_test_grade_category_get_sortorder() {
719        $category = new grade_category($this->grade_categories[0]);
720        $this->assertTrue(method_exists($category, 'get_sortorder'));
721        $category->load_grade_item();
722        $this->assertEquals($category->get_sortorder(), $category->grade_item->get_sortorder());
723    }
724
725    protected function sub_test_grade_category_set_sortorder() {
726        $category = new grade_category($this->grade_categories[0]);
727        $this->assertTrue(method_exists($category, 'set_sortorder'));
728        $category->load_grade_item();
729        $this->assertEquals($category->set_sortorder(10), $category->grade_item->set_sortorder(10));
730    }
731
732    protected function sub_test_grade_category_move_after_sortorder() {
733        $category = new grade_category($this->grade_categories[0]);
734        $this->assertTrue(method_exists($category, 'move_after_sortorder'));
735        $category->load_grade_item();
736        $this->assertEquals($category->move_after_sortorder(10), $category->grade_item->move_after_sortorder(10));
737    }
738
739    protected function sub_test_grade_category_is_course_category() {
740        $category = grade_category::fetch_course_category($this->courseid);
741        $this->assertTrue(method_exists($category, 'is_course_category'));
742        $this->assertTrue($category->is_course_category());
743    }
744
745    protected function sub_test_grade_category_fetch_course_category() {
746        $category = new grade_category();
747        $this->assertTrue(method_exists($category, 'fetch_course_category'));
748        $category = grade_category::fetch_course_category($this->courseid);
749        $this->assertTrue(empty($category->parent));
750    }
751    /**
752     * TODO implement
753     */
754    protected function sub_test_grade_category_is_editable() {
755
756    }
757
758    protected function sub_test_grade_category_is_locked() {
759        $category = new grade_category($this->grade_categories[0]);
760        $this->assertTrue(method_exists($category, 'is_locked'));
761        $category->load_grade_item();
762        $this->assertEquals($category->is_locked(), $category->grade_item->is_locked());
763    }
764
765    protected function sub_test_grade_category_set_locked() {
766        $category = new grade_category($this->grade_categories[0]);
767        $this->assertTrue(method_exists($category, 'set_locked'));
768
769        // Will return false as cannot lock a grade that needs updating.
770        $this->assertFalse($category->set_locked(1));
771        grade_regrade_final_grades($this->courseid);
772
773        // Get the category from the db again.
774        $category = new grade_category($this->grade_categories[0]);
775        $this->assertTrue($category->set_locked(1));
776    }
777
778    protected function sub_test_grade_category_is_hidden() {
779        $category = new grade_category($this->grade_categories[0]);
780        $this->assertTrue(method_exists($category, 'is_hidden'));
781        $category->load_grade_item();
782        $this->assertEquals($category->is_hidden(), $category->grade_item->is_hidden());
783    }
784
785    protected function sub_test_grade_category_set_hidden() {
786        $category = new grade_category($this->grade_categories[0]);
787        $this->assertTrue(method_exists($category, 'set_hidden'));
788        $category->set_hidden(1, true);
789        $category->load_grade_item();
790        $this->assertEquals(true, $category->grade_item->is_hidden());
791    }
792
793    protected function sub_test_grade_category_can_control_visibility() {
794        $category = new grade_category($this->grade_categories[0]);
795        $this->assertTrue($category->can_control_visibility());
796    }
797
798    protected function sub_test_grade_category_insert_course_category() {
799        // Beware: adding a duplicate course category messes up the data in a way that's hard to recover from.
800        $grade_category = new grade_category();
801        $this->assertTrue(method_exists($grade_category, 'insert_course_category'));
802
803        $id = $grade_category->insert_course_category($this->courseid);
804        $this->assertNotNull($id);
805        $this->assertEquals('?', $grade_category->fullname);
806        $this->assertEquals(GRADE_AGGREGATE_WEIGHTED_MEAN2, $grade_category->aggregation);
807        $this->assertEquals("/$id/", $grade_category->path);
808        $this->assertEquals(1, $grade_category->depth);
809        $this->assertNull($grade_category->parent);
810    }
811
812    protected function generate_random_raw_grade($item, $userid) {
813        $grade = new grade_grade();
814        $grade->itemid = $item->id;
815        $grade->userid = $userid;
816        $grade->grademin = 0;
817        $grade->grademax = 1;
818        $valuetype = "grade$item->gradetype";
819        $grade->rawgrade = rand(0, 1000) / 1000;
820        $grade->insert();
821        return $grade->rawgrade;
822    }
823
824    protected function sub_test_grade_category_is_extracredit_used() {
825        $category = new grade_category();
826        // Following use aggregationcoef.
827        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2;
828        $this->assertTrue($category->is_extracredit_used());
829        $category->aggregation = GRADE_AGGREGATE_EXTRACREDIT_MEAN;
830        $this->assertTrue($category->is_extracredit_used());
831        $category->aggregation = GRADE_AGGREGATE_SUM;
832        $this->assertTrue($category->is_extracredit_used());
833
834        // Following don't use aggregationcoef.
835        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN;
836        $this->assertFalse($category->is_extracredit_used());
837        $category->aggregation = GRADE_AGGREGATE_MAX;
838        $this->assertFalse($category->is_extracredit_used());
839        $category->aggregation = GRADE_AGGREGATE_MEAN;
840        $this->assertFalse($category->is_extracredit_used());
841        $category->aggregation = GRADE_AGGREGATE_MEDIAN;
842        $this->assertFalse($category->is_extracredit_used());
843        $category->aggregation = GRADE_AGGREGATE_MIN;
844        $this->assertFalse($category->is_extracredit_used());
845        $category->aggregation = GRADE_AGGREGATE_MODE;
846        $this->assertFalse($category->is_extracredit_used());
847    }
848
849    protected function sub_test_grade_category_aggregation_uses_extracredit() {
850
851        $this->assertTrue(grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_WEIGHTED_MEAN2));
852        $this->assertTrue(grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_EXTRACREDIT_MEAN));
853        $this->assertTrue(grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_SUM));
854
855        $this->assertFalse(grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_WEIGHTED_MEAN));
856        $this->assertFalse(grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MAX));
857        $this->assertFalse(grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MEAN));
858        $this->assertFalse(grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MEDIAN));
859        $this->assertFalse(grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MIN));
860        $this->assertFalse(grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MODE));
861    }
862
863    /**
864     * Test for category total visibility.
865     */
866    protected function sub_test_grade_category_total_visibility() {
867        // 15 is a manual grade item in grade_categories[5].
868        $category = new grade_category($this->grade_categories[5], true);
869        $gradeitem = new grade_item($this->grade_items[15], true);
870
871        // Hide grade category.
872        $category->set_hidden(true, true);
873        $this->assertTrue($category->is_hidden());
874        // Category total is hidden.
875        $categorytotal = $category->get_grade_item();
876        $this->assertTrue($categorytotal->is_hidden());
877        // Manual grade is hidden.
878        $gradeitem->update_from_db();
879        $this->assertTrue($gradeitem->is_hidden());
880
881        // Unhide manual grade item.
882        $gradeitem->set_hidden(false);
883        $this->assertFalse($gradeitem->is_hidden());
884        // Category is unhidden.
885        $category->update_from_db();
886        $this->assertFalse($category->is_hidden());
887        // Category total remain hidden.
888        $categorytotal = $category->get_grade_item();
889        $this->assertTrue($categorytotal->is_hidden());
890
891        // Edit manual grade item.
892        $this->assertFalse($gradeitem->is_locked());
893        $gradeitem->set_locked(true);
894        $gradeitem->update_from_db();
895        $this->assertTrue($gradeitem->is_locked());
896        // Category total should still be hidden.
897        $category->update_from_db();
898        $categorytotal = $category->get_grade_item();
899        $this->assertTrue($categorytotal->is_hidden());
900    }
901}
902