<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

namespace core;

defined('MOODLE_INTERNAL') || die();

require_once(__DIR__.'/fixtures/lib.php');


/**
 * Test grade categories
 *
 * @package    core
 * @category   test
 * @copyright  nicolas@moodle.com
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
final class grade_category_test extends \grade_base_testcase {

    public function test_grade_category(): void {
        $this->sub_test_grade_category_construct();
        $this->sub_test_grade_category_build_path();
        $this->sub_test_grade_category_fetch();
        $this->sub_test_grade_category_fetch_all();
        $this->sub_test_grade_category_update();
        $this->sub_test_grade_category_delete();
        $this->sub_test_grade_category_insert();
        $this->sub_test_grade_category_qualifies_for_regrading();
        $this->sub_test_grade_category_force_regrading();
        $this->sub_test_grade_category_aggregate_grades();
        $this->sub_test_grade_category_apply_limit_rules();
        $this->sub_test_grade_category_is_aggregationcoef_used();
        $this->sub_test_grade_category_aggregation_uses_aggregationcoef();
        $this->sub_test_grade_category_fetch_course_tree();
        $this->sub_test_grade_category_get_children();
        $this->sub_test_grade_category_load_grade_item();
        $this->sub_test_grade_category_get_grade_item();
        $this->sub_test_grade_category_load_parent_category();
        $this->sub_test_grade_category_get_parent_category();
        $this->sub_test_grade_category_get_name_escaped();
        $this->sub_test_grade_category_get_name_unescaped();
        $this->sub_test_grade_category_generate_grades_aggregationweight();
        $this->sub_test_grade_category_set_parent();
        $this->sub_test_grade_category_get_final();
        $this->sub_test_grade_category_get_sortorder();
        $this->sub_test_grade_category_set_sortorder();
        $this->sub_test_grade_category_is_editable();
        $this->sub_test_grade_category_move_after_sortorder();
        $this->sub_test_grade_category_is_course_category();
        $this->sub_test_grade_category_fetch_course_category();
        $this->sub_test_grade_category_is_locked();
        $this->sub_test_grade_category_set_locked();
        $this->sub_test_grade_category_is_hidden();
        $this->sub_test_grade_category_set_hidden();
        $this->sub_test_grade_category_can_control_visibility();
        $this->sub_test_grade_category_total_visibility();

        // This won't work until MDL-11837 is complete.
        // $this->sub_test_grade_category_generate_grades();

        // Do this last as adding a second course category messes up the data.
        $this->sub_test_grade_category_insert_course_category();
        $this->sub_test_grade_category_is_extracredit_used();
        $this->sub_test_grade_category_aggregation_uses_extracredit();
    }

    // Adds 3 new grade categories at various depths.
    protected function sub_test_grade_category_construct() {
        $course_category = \grade_category::fetch_course_category($this->courseid);

        $params = new \stdClass();

        $params->courseid = $this->courseid;
        $params->fullname = 'unittestcategory4';

        $grade_category = new \grade_category($params, false);
        $grade_category->insert();
        $this->grade_categories[] = $grade_category;

        $this->assertEquals($params->courseid, $grade_category->courseid);
        $this->assertEquals($params->fullname, $grade_category->fullname);
        $this->assertEquals(2, $grade_category->depth);
        $this->assertEquals("/$course_category->id/$grade_category->id/", $grade_category->path);
        $parentpath = $grade_category->path;

        // Test a child category.
        $params->parent = $grade_category->id;
        $params->fullname = 'unittestcategory5';
        $grade_category = new \grade_category($params, false);
        $grade_category->insert();
        $this->grade_categories[] = $grade_category;

        $this->assertEquals(3, $grade_category->depth);
        $this->assertEquals($parentpath.$grade_category->id."/", $grade_category->path);
        $parentpath = $grade_category->path;

        // Test a third depth category.
        $params->parent = $grade_category->id;
        $params->fullname = 'unittestcategory6';
        $grade_category = new \grade_category($params, false);
        $grade_category->insert();
        $this->grade_categories[50] = $grade_category;// Going to delete this one later hence the special index.

        $this->assertEquals(4, $grade_category->depth);
        $this->assertEquals($parentpath.$grade_category->id."/", $grade_category->path);
    }

    protected function sub_test_grade_category_build_path() {
        $grade_category = new \grade_category($this->grade_categories[1]);
        $this->assertTrue(method_exists($grade_category, 'build_path'));
        $path = \grade_category::build_path($grade_category);
        $this->assertEquals($grade_category->path, $path);
    }

    protected function sub_test_grade_category_fetch() {
        $grade_category = new \grade_category();
        $this->assertTrue(method_exists($grade_category, 'fetch'));

        $grade_category = \grade_category::fetch(array('id'=>$this->grade_categories[0]->id));
        $this->assertEquals($this->grade_categories[0]->id, $grade_category->id);
        $this->assertEquals($this->grade_categories[0]->fullname, $grade_category->fullname);
    }

    protected function sub_test_grade_category_fetch_all() {
        $grade_category = new \grade_category();
        $this->assertTrue(method_exists($grade_category, 'fetch_all'));

        $grade_categories = \grade_category::fetch_all(array('courseid'=>$this->courseid));
        $this->assertEquals(count($this->grade_categories), count($grade_categories)-1);
    }

    protected function sub_test_grade_category_update() {
        global $DB;
        $grade_category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($grade_category, 'update'));

        $grade_category->fullname = 'Updated info for this unittest grade_category';
        $grade_category->path = null; // Path must be recalculated if missing.
        $grade_category->depth = null;
        $grade_category->aggregation = GRADE_AGGREGATE_MAX; // Should force regrading.

        $grade_item = $grade_category->get_grade_item();
        $this->assertEquals(0, $grade_item->needsupdate);

        $this->assertTrue($grade_category->update());

        $fullname = $DB->get_field('grade_categories', 'fullname', array('id' => $this->grade_categories[0]->id));
        $this->assertEquals($grade_category->fullname, $fullname);

        $path = $DB->get_field('grade_categories', 'path', array('id' => $this->grade_categories[0]->id));
        $this->assertEquals($grade_category->path, $path);

        $depth = $DB->get_field('grade_categories', 'depth', array('id' => $this->grade_categories[0]->id));
        $this->assertEquals($grade_category->depth, $depth);

        $grade_item = $grade_category->get_grade_item();
        $this->assertEquals(1, $grade_item->needsupdate);
    }

    protected function sub_test_grade_category_delete() {
        global $DB;

        $grade_category = new \grade_category($this->grade_categories[50]);
        $this->assertTrue(method_exists($grade_category, 'delete'));

        $this->assertTrue($grade_category->delete());
        $this->assertFalse($DB->get_record('grade_categories', array('id' => $grade_category->id)));
    }

    protected function sub_test_grade_category_insert() {
        $course_category = \grade_category::fetch_course_category($this->courseid);

        $grade_category = new \grade_category();
        $this->assertTrue(method_exists($grade_category, 'insert'));

        $grade_category->fullname    = 'unittestcategory4';
        $grade_category->courseid    = $this->courseid;
        $grade_category->aggregation = GRADE_AGGREGATE_MEAN;
        $grade_category->aggregateonlygraded = 1;
        $grade_category->keephigh    = 100;
        $grade_category->droplow     = 10;
        $grade_category->hidden      = 0;
        $grade_category->parent      = $this->grade_categories[1]->id; // sub_test_grade_category_delete() removed the category at 0.

        $grade_category->insert();

        $this->assertEquals('/'.$course_category->id.'/'.$this->grade_categories[1]->parent.'/'.$this->grade_categories[1]->id.'/'.$grade_category->id.'/', $grade_category->path);
        $this->assertEquals(4, $grade_category->depth);

        $last_grade_category = end($this->grade_categories);

        $this->assertFalse(empty($grade_category->grade_item));
        $this->assertEquals($grade_category->id, $grade_category->grade_item->iteminstance);
        $this->assertEquals('category', $grade_category->grade_item->itemtype);

        $this->assertEquals($grade_category->id, $last_grade_category->id + 1);
        $this->assertFalse(empty($grade_category->timecreated));
        $this->assertFalse(empty($grade_category->timemodified));
    }

    protected function sub_test_grade_category_qualifies_for_regrading() {
        $grade_category = new \grade_category($this->grade_categories[1]);
        $this->assertTrue(method_exists($grade_category, 'qualifies_for_regrading'));
        $this->assertFalse($grade_category->qualifies_for_regrading());

        $grade_category->aggregation = GRADE_AGGREGATE_MAX;
        $this->assertTrue($grade_category->qualifies_for_regrading());

        $grade_category = new \grade_category($this->grade_categories[1]);
        $grade_category->droplow = 99;
        $this->assertTrue($grade_category->qualifies_for_regrading());

        $grade_category = new \grade_category($this->grade_categories[1]);
        $grade_category->keephigh = 99;
        $this->assertTrue($grade_category->qualifies_for_regrading());
    }

    protected function sub_test_grade_category_force_regrading() {
        $grade_category = new \grade_category($this->grade_categories[1]);
        $this->assertTrue(method_exists($grade_category, 'force_regrading'));

        $grade_category->load_grade_item();
        $this->assertEquals(0, $grade_category->grade_item->needsupdate);

        $grade_category->force_regrading();

        $grade_category->grade_item = null;
        $grade_category->load_grade_item();

        $this->assertEquals(1, $grade_category->grade_item->needsupdate);
    }

    /**
     * Tests the setting of the grade_grades aggregationweight column.
     * Currently, this is only a regression test for MDL-51715.
     * This must be run before sub_test_grade_category_set_parent(), which alters
     * the fixture.
     */
    protected function sub_test_grade_category_generate_grades_aggregationweight() {
        global $DB;

        // Start of regression test for MDL-51715.
        // grade_categories [1] and [2] are child categories of [0]
        // Ensure that grades have been generated with fixture data.
        $childcat1 = new \grade_category($this->grade_categories[1]);
        $childcat1itemid = $childcat1->load_grade_item()->id;
        $childcat1->generate_grades();
        $childcat2 = new \grade_category($this->grade_categories[2]);
        $childcat2itemid = $childcat2->load_grade_item()->id;
        $childcat2->generate_grades();
        $parentcat = new \grade_category($this->grade_categories[0]);
        $parentcat->generate_grades();

        // Drop low and and re-generate to produce 'dropped' aggregation status.
        $parentcat->droplow = 1;
        $parentcat->generate_grades();

        $this->assertTrue($DB->record_exists_select(
                                     'grade_grades',
                                     "aggregationstatus='dropped' and itemid in (?,?)",
                                     array($childcat1itemid, $childcat2itemid)));
        $this->assertFalse($DB->record_exists_select(
                                     'grade_grades',
                                     "aggregationstatus='dropped' and aggregationweight > 0.00"),
                           "aggregationweight should be 0.00 if aggregationstatus=='dropped'");

        // Reset grade data to be consistent with fixture data.
        $parentcat->droplow = 0;
        $parentcat->generate_grades();

        // Blank out the final grade for one of the child categories and re-generate
        // to produce 'novalue' aggregationstatus.  Direct DB update is testing shortcut.
        $DB->set_field('grade_grades', 'finalgrade', null, array('itemid'=>$childcat1itemid));
        $parentcat->generate_grades();

        $this->assertFalse($DB->record_exists_select(
                                     'grade_grades',
                                     "aggregationstatus='dropped' and itemid in (?,?)",
                                     array($childcat1itemid, $childcat2itemid)));
        $this->assertTrue($DB->record_exists_select(
                                     'grade_grades',
                                     "aggregationstatus='novalue' and itemid = ?",
                                     array($childcat1itemid)));
        $this->assertFalse($DB->record_exists_select(
                                     'grade_grades',
                                     "aggregationstatus='novalue' and aggregationweight > 0.00"),
                           "aggregationweight should be 0.00 if aggregationstatus=='novalue'");

        // Re-generate to be consistent with fixture data.
        $childcat1->generate_grades();
        $parentcat->generate_grades();
        // End of regression test for MDL-51715.
    }

    /**
     * Tests the calculation of grades using the various aggregation methods with and without hidden grades
     * This will not work entirely until MDL-11837 is done
     */
    protected function sub_test_grade_category_generate_grades() {
        global $DB;

        // Inserting some special grade items to make testing the final grade calculation easier.
        $params = new \stdClass();
        $params->courseid = $this->courseid;
        $params->fullname = 'unittestgradecalccategory';
        $params->aggregation = GRADE_AGGREGATE_MEAN;
        $params->aggregateonlygraded = 0;
        $grade_category = new \grade_category($params, false);
        $grade_category->insert();

        $this->assertTrue(method_exists($grade_category, 'generate_grades'));

        $grade_category->load_grade_item();
        $cgi = $grade_category->get_grade_item();
        $cgi->grademin = 0;
        $cgi->grademax = 20; // 3 grade items out of 10 but category is out of 20 to force scaling to occur.
        $cgi->update();

        // 3 grade items each with a maximum grade of 10.
        $grade_items = array();
        for ($i=0; $i<3; $i++) {
            $grade_items[$i] = new \grade_item();
            $grade_items[$i]->courseid = $this->courseid;
            $grade_items[$i]->categoryid = $grade_category->id;
            $grade_items[$i]->itemname = 'manual grade_item '.$i;
            $grade_items[$i]->itemtype = 'manual';
            $grade_items[$i]->itemnumber = 0;
            $grade_items[$i]->needsupdate = false;
            $grade_items[$i]->gradetype = GRADE_TYPE_VALUE;
            $grade_items[$i]->grademin = 0;
            $grade_items[$i]->grademax = 10;
            $grade_items[$i]->iteminfo = 'Manual grade item used for unit testing';
            $grade_items[$i]->timecreated = time();
            $grade_items[$i]->timemodified = time();

            // Used as the weight by weighted mean and as extra credit by mean with extra credit.
            // Will be 0, 1 and 2.
            $grade_items[$i]->aggregationcoef = $i;

            $grade_items[$i]->insert();
        }

        // A grade for each grade item.
        $grade_grades = array();
        for ($i=0; $i<3; $i++) {
            $grade_grades[$i] = new \grade_grade();
            $grade_grades[$i]->itemid = $grade_items[$i]->id;
            $grade_grades[$i]->userid = $this->userid;
            $grade_grades[$i]->rawgrade = ($i+1)*2; // Produce grade grades of 2, 4 and 6.
            $grade_grades[$i]->finalgrade = ($i+1)*2;
            $grade_grades[$i]->timecreated = time();
            $grade_grades[$i]->timemodified = time();
            $grade_grades[$i]->information = '1 of 2 grade_grades';
            $grade_grades[$i]->informationformat = FORMAT_PLAIN;
            $grade_grades[$i]->feedback = 'Good, but not good enough..';
            $grade_grades[$i]->feedbackformat = FORMAT_PLAIN;

            $grade_grades[$i]->insert();
        }

        // 3 grade items with 1 grade_grade each.
        // grade grades have the values 2, 4 and 6.

        // First correct answer is the aggregate with all 3 grades.
        // Second correct answer is with the first grade (value 2) hidden.

        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_MEDIAN, 'GRADE_AGGREGATE_MEDIAN', 8, 8);
        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_MAX, 'GRADE_AGGREGATE_MAX', 12, 12);
        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_MODE, 'GRADE_AGGREGATE_MODE', 12, 12);

        // Weighted mean. note grade totals are rounded to an int to prevent rounding discrepancies. correct final grade isnt actually exactly 10
        // 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.
        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_WEIGHTED_MEAN, 'GRADE_AGGREGATE_WEIGHTED_MEAN', 10, 10);

        // Simple weighted mean.
        // 3 items with grades 2, 4 and 6 equally weighted and all out of 10. then doubled to be out of 20.
        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_WEIGHTED_MEAN2, 'GRADE_AGGREGATE_WEIGHTED_MEAN2', 8, 10);

        // Mean of grades with extra credit.
        // 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.
        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_EXTRACREDIT_MEAN, 'GRADE_AGGREGATE_EXTRACREDIT_MEAN', 10, 13);

        // Aggregation tests the are affected by a hidden grade currently dont work as we dont store the altered grade in the database
        // instead an in memory recalculation is done. This should be remedied by MDL-11837.

        // Fails with 1 grade hidden. still reports 8 as being correct.
        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_MEAN, 'GRADE_AGGREGATE_MEAN', 8, 10);

        // Fails with 1 grade hidden. still reports 4 as being correct.
        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_MIN, 'GRADE_AGGREGATE_MIN', 4, 8);

        // Fails with 1 grade hidden. still reports 12 as being correct.
        $this->helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, GRADE_AGGREGATE_SUM, 'GRADE_AGGREGATE_SUM', 12, 10);
    }

    /**
     * Test grade category aggregation using the supplied grade objects and aggregation method
     * @param \grade_category $grade_category the category to be tested
     * @param array $grade_items array of instance of grade_item
     * @param array $grade_grades array of instances of grade_grade
     * @param int $aggmethod the aggregation method to apply ie GRADE_AGGREGATE_MEAN
     * @param string $aggmethodname the name of the aggregation method to apply. Used to display any test failure messages
     * @param int $correct1 the correct final grade for the category with NO items hidden
     * @param int $correct2 the correct final grade for the category with the grade at $grade_grades[0] hidden
     * @return void
     */
    protected function helper_test_grade_agg_method($grade_category, $grade_items, $grade_grades, $aggmethod, $aggmethodname, $correct1, $correct2) {
        $grade_category->aggregation = $aggmethod;
        $grade_category->update();

        // Check grade_item isnt hidden from a previous test.
        $grade_items[0]->set_hidden(0, true);
        $this->helper_test_grade_aggregation_result($grade_category, $correct1, 'Testing aggregation method('.$aggmethodname.') with no items hidden %s');

        // Hide the grade item with grade of 2.
        $grade_items[0]->set_hidden(1, true);
        $this->helper_test_grade_aggregation_result($grade_category, $correct2, 'Testing aggregation method('.$aggmethodname.') with 1 item hidden %s');
    }

    /**
     * Verify the value of the category grade item for $this->userid
     * @param \grade_category $grade_category the category to be tested
     * @param int $correctgrade the expected grade
     * @param string $msg The message that should be displayed if the correct grade is not found
     * @return void
     */
    protected function helper_test_grade_aggregation_result($grade_category, $correctgrade, $msg) {
        global $DB;

        $category_grade_item = $grade_category->get_grade_item();

        // This creates all the grade_grades we need.
        grade_regrade_final_grades($this->courseid);

        $grade = $DB->get_record('grade_grades', array('itemid'=>$category_grade_item->id, 'userid'=>$this->userid));
        $this->assertWithinMargin($grade->rawgrade, $grade->rawgrademin, $grade->rawgrademax);
        $this->assertEquals(intval($correctgrade), intval($grade->finalgrade), $msg);

        /*
         * TODO this doesnt work as the grade_grades created by $grade_category->generate_grades(); dont
         * observe the category's max grade
        // delete the grade_grades for the category itself and check they get recreated correctly.
        $DB->delete_records('grade_grades', array('itemid'=>$category_grade_item->id));
        $grade_category->generate_grades();

        $grade = $DB->get_record('grade_grades', array('itemid'=>$category_grade_item->id, 'userid'=>$this->userid));
        $this->assertWithinMargin($grade->rawgrade, $grade->rawgrademin, $grade->rawgrademax);
        $this->assertEquals(intval($correctgrade), intval($grade->finalgrade), $msg);
         *
         */
    }

    protected function sub_test_grade_category_aggregate_grades() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'aggregate_grades'));
        // Tested more fully via test_grade_category_generate_grades().
    }

    protected function sub_test_grade_category_apply_limit_rules() {
        $items[$this->grade_items[0]->id] = new \grade_item($this->grade_items[0], false);
        $items[$this->grade_items[1]->id] = new \grade_item($this->grade_items[1], false);
        $items[$this->grade_items[2]->id] = new \grade_item($this->grade_items[2], false);
        $items[$this->grade_items[4]->id] = new \grade_item($this->grade_items[4], false);

        // Test excluding the lowest 2 out of 4 grades from aggregation with no 0 grades.
        $category = new \grade_category();
        $category->droplow = 2;
        $grades = array($this->grade_items[0]->id=>5.374,
                        $this->grade_items[1]->id=>9.4743,
                        $this->grade_items[2]->id=>2.5474,
                        $this->grade_items[4]->id=>7.3754);
        $category->apply_limit_rules($grades, $items);
        $this->assertEquals(count($grades), 2);
        $this->assertEquals($grades[$this->grade_items[1]->id], 9.4743);
        $this->assertEquals($grades[$this->grade_items[4]->id], 7.3754);

        // Test aggregating only the highest 1 out of 4 grades.
        $category = new \grade_category();
        $category->keephigh = 1;
        $category->droplow = 0;
        $grades = array($this->grade_items[0]->id=>5.374,
                        $this->grade_items[1]->id=>9.4743,
                        $this->grade_items[2]->id=>2.5474,
                        $this->grade_items[4]->id=>7.3754);
        $category->apply_limit_rules($grades, $items);
        $this->assertEquals(count($grades), 1);
        $grade = reset($grades);
        $this->assertEquals(9.4743, $grade);

        // Test excluding the lowest 2 out of 4 grades from aggregation with no 0 grades.
        // An extra credit grade item should be kept even if droplow means it would otherwise be excluded.
        $category = new \grade_category();
        $category->droplow     = 2;
        $category->aggregation = GRADE_AGGREGATE_SUM;
        $items[$this->grade_items[2]->id]->aggregationcoef = 1; // Mark grade item 2 as "extra credit".
        $grades = array($this->grade_items[0]->id=>5.374,
                        $this->grade_items[1]->id=>9.4743,
                        $this->grade_items[2]->id=>2.5474,
                        $this->grade_items[4]->id=>7.3754);
        $category->apply_limit_rules($grades, $items);
        $this->assertEquals(count($grades), 2);
        $this->assertEquals($grades[$this->grade_items[1]->id], 9.4743);
        $this->assertEquals($grades[$this->grade_items[2]->id], 2.5474);

        // Test only aggregating the highest 1 out of 4 grades.
        // An extra credit grade item is retained in addition to the highest grade.
        $category = new \grade_category();
        $category->keephigh = 1;
        $category->droplow = 0;
        $category->aggregation = GRADE_AGGREGATE_SUM;
        $items[$this->grade_items[2]->id]->aggregationcoef = 1; // Mark grade item 2 as "extra credit".
        $grades = array($this->grade_items[0]->id=>5.374,
                        $this->grade_items[1]->id=>9.4743,
                        $this->grade_items[2]->id=>2.5474,
                        $this->grade_items[4]->id=>7.3754);
        $category->apply_limit_rules($grades, $items);
        $this->assertEquals(count($grades), 2);
        $this->assertEquals($grades[$this->grade_items[1]->id], 9.4743);
        $this->assertEquals($grades[$this->grade_items[2]->id], 2.5474);

        // Test excluding the lowest 1 out of 4 grades from aggregation with two 0 grades.
        $items[$this->grade_items[2]->id]->aggregationcoef = 0; // Undo marking grade item 2 as "extra credit".
        $category = new \grade_category();
        $category->droplow     = 1;
        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2; // Simple weighted mean.
        $grades = array($this->grade_items[0]->id=>0, // 0 out of 110. Should be excluded from aggregation.
                        $this->grade_items[1]->id=>5, // 5 out of 100.
                        $this->grade_items[2]->id=>2, // 0 out of 6.
                        $this->grade_items[4]->id=>0); // 0 out of 100.
        $category->apply_limit_rules($grades, $items);
        $this->assertEquals(count($grades), 3);
        $this->assertEquals($grades[$this->grade_items[1]->id], 5);
        $this->assertEquals($grades[$this->grade_items[2]->id], 2);
        $this->assertEquals($grades[$this->grade_items[4]->id], 0);

        // Test excluding the lowest 2 out of 4 grades from aggregation with three 0 grades.
        $category = new \grade_category();
        $category->droplow     = 2;
        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2; // Simple weighted mean.
        $grades = array($this->grade_items[0]->id=>0, // 0 out of 110. Should be excluded from aggregation.
                        $this->grade_items[1]->id=>5, // 5 out of 100.
                        $this->grade_items[2]->id=>0, // 0 out of 6.
                        $this->grade_items[4]->id=>0); // 0 out of 100. Should be excluded from aggregation.
        $category->apply_limit_rules($grades, $items);
        $this->assertEquals(count($grades), 2);
        $this->assertEquals($grades[$this->grade_items[1]->id], 5);
        $this->assertEquals($grades[$this->grade_items[2]->id], 0);

        // Test excluding the lowest 5 out of 4 grades from aggregation.
        // Just to check we handle this sensibly.
        $category = new \grade_category();
        $category->droplow     = 5;
        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2; // Simple weighted mean.
        $grades = array($this->grade_items[0]->id=>0, // 0 out of 110. Should be excluded from aggregation.
                        $this->grade_items[1]->id=>5, // 5 out of 100.
                        $this->grade_items[2]->id=>6, // 6 out of 6.
                        $this->grade_items[4]->id=>1);// 1 out of 100. Should be excluded from aggregation.
        $category->apply_limit_rules($grades, $items);
        $this->assertEquals(count($grades), 0);

        // Test excluding the lowest 4 out of 4 grades from aggregation with one marked as extra credit.
        $category = new \grade_category();
        $category->droplow     = 4;
        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2; // Simple weighted mean.
        $items[$this->grade_items[2]->id]->aggregationcoef = 1; // Mark grade item 2 as "extra credit".
        $grades = array($this->grade_items[0]->id=>0, // 0 out of 110. Should be excluded from aggregation.
                        $this->grade_items[1]->id=>5, // 5 out of 100. Should be excluded from aggregation.
                        $this->grade_items[2]->id=>6, // 6 out of 6. Extra credit. Should be retained.
                        $this->grade_items[4]->id=>1);// 1 out of 100. Should be excluded from aggregation.
        $category->apply_limit_rules($grades, $items);
        $this->assertEquals(count($grades), 1);
        $this->assertEquals($grades[$this->grade_items[2]->id], 6);

        // MDL-35667 - There was an infinite loop if several items had the same grade and at least one was extra credit.
        $category = new \grade_category();
        $category->droplow     = 1;
        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2; // Simple weighted mean.
        $items[$this->grade_items[1]->id]->aggregationcoef = 1; // Mark grade item 1 as "extra credit".
        $grades = array($this->grade_items[0]->id=>1, // 1 out of 110. Should be excluded from aggregation.
                        $this->grade_items[1]->id=>1, // 1 out of 100. Extra credit. Should be retained.
                        $this->grade_items[2]->id=>1, // 1 out of 6. Should be retained.
                        $this->grade_items[4]->id=>1);// 1 out of 100. Should be retained.
        $category->apply_limit_rules($grades, $items);
        $this->assertEquals(count($grades), 3);
        $this->assertEquals($grades[$this->grade_items[1]->id], 1);
        $this->assertEquals($grades[$this->grade_items[2]->id], 1);
        $this->assertEquals($grades[$this->grade_items[4]->id], 1);

    }

    protected function sub_test_grade_category_is_aggregationcoef_used() {
        $category = new \grade_category();
        // Following use aggregationcoef.
        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN;
        $this->assertTrue($category->is_aggregationcoef_used());
        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2;
        $this->assertTrue($category->is_aggregationcoef_used());
        $category->aggregation = GRADE_AGGREGATE_EXTRACREDIT_MEAN;
        $this->assertTrue($category->is_aggregationcoef_used());
        $category->aggregation = GRADE_AGGREGATE_SUM;
        $this->assertTrue($category->is_aggregationcoef_used());

        // Following don't use aggregationcoef.
        $category->aggregation = GRADE_AGGREGATE_MAX;
        $this->assertFalse($category->is_aggregationcoef_used());
        $category->aggregation = GRADE_AGGREGATE_MEAN;
        $this->assertFalse($category->is_aggregationcoef_used());
        $category->aggregation = GRADE_AGGREGATE_MEDIAN;
        $this->assertFalse($category->is_aggregationcoef_used());
        $category->aggregation = GRADE_AGGREGATE_MIN;
        $this->assertFalse($category->is_aggregationcoef_used());
        $category->aggregation = GRADE_AGGREGATE_MODE;
        $this->assertFalse($category->is_aggregationcoef_used());
    }

    protected function sub_test_grade_category_aggregation_uses_aggregationcoef() {

        $this->assertTrue(\grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_WEIGHTED_MEAN));
        $this->assertTrue(\grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_WEIGHTED_MEAN2));
        $this->assertTrue(\grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_EXTRACREDIT_MEAN));
        $this->assertTrue(\grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_SUM));

        $this->assertFalse(\grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_MAX));
        $this->assertFalse(\grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_MEAN));
        $this->assertFalse(\grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_MEDIAN));
        $this->assertFalse(\grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_MIN));
        $this->assertFalse(\grade_category::aggregation_uses_aggregationcoef(GRADE_AGGREGATE_MODE));
    }

    protected function sub_test_grade_category_fetch_course_tree() {
        $category = new \grade_category();
        $this->assertTrue(method_exists($category, 'fetch_course_tree'));
        // TODO: add some tests.
    }

    protected function sub_test_grade_category_get_children() {
        $course_category = \grade_category::fetch_course_category($this->courseid);

        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'get_children'));

        $children_array = $category->get_children(0);

        $this->assertTrue(is_array($children_array));
        $this->assertFalse(empty($children_array[2]));
        $this->assertFalse(empty($children_array[2]['object']));
        $this->assertFalse(empty($children_array[2]['children']));
        $this->assertEquals($this->grade_categories[1]->id, $children_array[2]['object']->id);
        $this->assertEquals($this->grade_categories[2]->id, $children_array[5]['object']->id);
        $this->assertEquals($this->grade_items[0]->id, $children_array[2]['children'][3]['object']->id);
        $this->assertEquals($this->grade_items[1]->id, $children_array[2]['children'][4]['object']->id);
        $this->assertEquals($this->grade_items[2]->id, $children_array[5]['children'][6]['object']->id);
    }

    protected function sub_test_grade_category_load_grade_item() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'load_grade_item'));
        $this->assertEquals(null, $category->grade_item);
        $category->load_grade_item();
        $this->assertEquals($this->grade_items[3]->id, $category->grade_item->id);
    }

    protected function sub_test_grade_category_get_grade_item() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'get_grade_item'));
        $grade_item = $category->get_grade_item();
        $this->assertEquals($this->grade_items[3]->id, $grade_item->id);
    }

    protected function sub_test_grade_category_load_parent_category() {
        $category = new \grade_category($this->grade_categories[1]);
        $this->assertTrue(method_exists($category, 'load_parent_category'));
        $this->assertEquals(null, $category->parent_category);
        $category->load_parent_category();
        $this->assertEquals($this->grade_categories[0]->id, $category->parent_category->id);
    }

    protected function sub_test_grade_category_get_parent_category() {
        $category = new \grade_category($this->grade_categories[1]);
        $this->assertTrue(method_exists($category, 'get_parent_category'));
        $parent_category = $category->get_parent_category();
        $this->assertEquals($this->grade_categories[0]->id, $parent_category->id);
    }

    /**
     * Tests the getter of the category fullname with escaped HTML.
     */
    protected function sub_test_grade_category_get_name_escaped() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'get_name'));
        $this->assertEquals(format_string($this->grade_categories[0]->fullname, true, ['escape' => true]),
            $category->get_name(true));
    }

    /**
     * Tests the getter of the category fullname with unescaped HTML.
     */
    protected function sub_test_grade_category_get_name_unescaped() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'get_name'));
        $this->assertEquals(format_string($this->grade_categories[0]->fullname, true, ['escape' => false]),
            $category->get_name(false));
    }

    protected function sub_test_grade_category_set_parent() {
        $category = new \grade_category($this->grade_categories[1]);
        $this->assertTrue(method_exists($category, 'set_parent'));
        // TODO: implement detailed tests.

        $course_category = \grade_category::fetch_course_category($this->courseid);
        $this->assertTrue($category->set_parent($course_category->id));
        $this->assertEquals($course_category->id, $category->parent);
    }

    protected function sub_test_grade_category_get_final() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'get_final'));
        $category->load_grade_item();
        $this->assertEquals($category->get_final(), $category->grade_item->get_final());
    }

    protected function sub_test_grade_category_get_sortorder() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'get_sortorder'));
        $category->load_grade_item();
        $this->assertEquals($category->get_sortorder(), $category->grade_item->get_sortorder());
    }

    protected function sub_test_grade_category_set_sortorder() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'set_sortorder'));
        $category->load_grade_item();
        $this->assertEquals($category->set_sortorder(10), $category->grade_item->set_sortorder(10));
    }

    protected function sub_test_grade_category_move_after_sortorder() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'move_after_sortorder'));
        $category->load_grade_item();
        $this->assertEquals($category->move_after_sortorder(10), $category->grade_item->move_after_sortorder(10));
    }

    protected function sub_test_grade_category_is_course_category() {
        $category = \grade_category::fetch_course_category($this->courseid);
        $this->assertTrue(method_exists($category, 'is_course_category'));
        $this->assertTrue($category->is_course_category());
    }

    protected function sub_test_grade_category_fetch_course_category() {
        $category = new \grade_category();
        $this->assertTrue(method_exists($category, 'fetch_course_category'));
        $category = \grade_category::fetch_course_category($this->courseid);
        $this->assertTrue(empty($category->parent));
    }
    /**
     * TODO implement
     */
    protected function sub_test_grade_category_is_editable() {

    }

    protected function sub_test_grade_category_is_locked() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'is_locked'));
        $category->load_grade_item();
        $this->assertEquals($category->is_locked(), $category->grade_item->is_locked());
    }

    protected function sub_test_grade_category_set_locked() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'set_locked'));

        // Even though a grade that needs updating cannot be locked, set_locked will return true because it will successfully
        // schedule the locking for as soon as final grades are recalculated.
        $this->assertTrue($category->set_locked(1));
        // The category should not be locked yet as we are waiting for a recalculation.
        $this->assertFalse($category->is_locked());
        grade_regrade_final_grades($this->courseid);

        // Get the category from the db again.
        $category = new \grade_category($this->grade_categories[0]);
        // The category is locked now.
        $this->assertTrue($category->is_locked());
    }

    protected function sub_test_grade_category_is_hidden() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'is_hidden'));
        $category->load_grade_item();
        $this->assertEquals($category->is_hidden(), $category->grade_item->is_hidden());
    }

    protected function sub_test_grade_category_set_hidden() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue(method_exists($category, 'set_hidden'));
        $category->set_hidden(1, true);
        $category->load_grade_item();
        $this->assertEquals(true, $category->grade_item->is_hidden());
    }

    protected function sub_test_grade_category_can_control_visibility() {
        $category = new \grade_category($this->grade_categories[0]);
        $this->assertTrue($category->can_control_visibility());
    }

    protected function sub_test_grade_category_insert_course_category() {
        // Beware: adding a duplicate course category messes up the data in a way that's hard to recover from.
        $grade_category = new \grade_category();
        $this->assertTrue(method_exists($grade_category, 'insert_course_category'));

        $id = $grade_category->insert_course_category($this->courseid);
        $this->assertNotNull($id);
        $this->assertEquals('?', $grade_category->fullname);
        $this->assertEquals(GRADE_AGGREGATE_WEIGHTED_MEAN2, $grade_category->aggregation);
        $this->assertEquals("/$id/", $grade_category->path);
        $this->assertEquals(1, $grade_category->depth);
        $this->assertNull($grade_category->parent);
    }

    protected function generate_random_raw_grade($item, $userid) {
        $grade = new \grade_grade();
        $grade->itemid = $item->id;
        $grade->userid = $userid;
        $grade->grademin = 0;
        $grade->grademax = 1;
        $valuetype = "grade$item->gradetype";
        $grade->rawgrade = rand(0, 1000) / 1000;
        $grade->insert();
        return $grade->rawgrade;
    }

    protected function sub_test_grade_category_is_extracredit_used() {
        $category = new \grade_category();
        // Following use aggregationcoef.
        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2;
        $this->assertTrue($category->is_extracredit_used());
        $category->aggregation = GRADE_AGGREGATE_EXTRACREDIT_MEAN;
        $this->assertTrue($category->is_extracredit_used());
        $category->aggregation = GRADE_AGGREGATE_SUM;
        $this->assertTrue($category->is_extracredit_used());

        // Following don't use aggregationcoef.
        $category->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN;
        $this->assertFalse($category->is_extracredit_used());
        $category->aggregation = GRADE_AGGREGATE_MAX;
        $this->assertFalse($category->is_extracredit_used());
        $category->aggregation = GRADE_AGGREGATE_MEAN;
        $this->assertFalse($category->is_extracredit_used());
        $category->aggregation = GRADE_AGGREGATE_MEDIAN;
        $this->assertFalse($category->is_extracredit_used());
        $category->aggregation = GRADE_AGGREGATE_MIN;
        $this->assertFalse($category->is_extracredit_used());
        $category->aggregation = GRADE_AGGREGATE_MODE;
        $this->assertFalse($category->is_extracredit_used());
    }

    protected function sub_test_grade_category_aggregation_uses_extracredit() {

        $this->assertTrue(\grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_WEIGHTED_MEAN2));
        $this->assertTrue(\grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_EXTRACREDIT_MEAN));
        $this->assertTrue(\grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_SUM));

        $this->assertFalse(\grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_WEIGHTED_MEAN));
        $this->assertFalse(\grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MAX));
        $this->assertFalse(\grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MEAN));
        $this->assertFalse(\grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MEDIAN));
        $this->assertFalse(\grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MIN));
        $this->assertFalse(\grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MODE));
    }

    /**
     * Test for category total visibility.
     */
    protected function sub_test_grade_category_total_visibility() {
        // 15 is a manual grade item in grade_categories[5].
        $category = new \grade_category($this->grade_categories[5], true);
        $gradeitem = new \grade_item($this->grade_items[15], true);

        // Hide grade category.
        $category->set_hidden(true, true);
        $this->assertTrue($category->is_hidden());
        // Category total is hidden.
        $categorytotal = $category->get_grade_item();
        $this->assertTrue($categorytotal->is_hidden());
        // Manual grade is hidden.
        $gradeitem->update_from_db();
        $this->assertTrue($gradeitem->is_hidden());

        // Unhide manual grade item.
        $gradeitem->set_hidden(false);
        $this->assertFalse($gradeitem->is_hidden());
        // Category is unhidden.
        $category->update_from_db();
        $this->assertFalse($category->is_hidden());
        // Category total remain hidden.
        $categorytotal = $category->get_grade_item();
        $this->assertTrue($categorytotal->is_hidden());

        // Edit manual grade item.
        $this->assertFalse($gradeitem->is_locked());
        $gradeitem->set_locked(true);
        $gradeitem->update_from_db();
        $this->assertTrue($gradeitem->is_locked());
        // Category total should still be hidden.
        $category->update_from_db();
        $categorytotal = $category->get_grade_item();
        $this->assertTrue($categorytotal->is_hidden());
    }
}
