<?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/>.

/**
 * Completion tests.
 *
 * @package    core_completion
 * @category   phpunit
 * @copyright  2008 Sam Marshall
 * @copyright  2013 Frédéric Massart
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

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

global $CFG;
require_once($CFG->libdir.'/completionlib.php');

class core_completionlib_testcase extends advanced_testcase {
    protected $course;
    protected $user;
    protected $module1;
    protected $module2;

    protected function mock_setup() {
        global $DB, $CFG, $USER;

        $this->resetAfterTest();

        $DB = $this->createMock(get_class($DB));
        $CFG->enablecompletion = COMPLETION_ENABLED;
        $USER = (object)array('id' =>314159);
    }

    /**
     * Create course with user and activities.
     */
    protected function setup_data() {
        global $DB, $CFG;

        $this->resetAfterTest();

        // Enable completion before creating modules, otherwise the completion data is not written in DB.
        $CFG->enablecompletion = true;

        // Create a course with activities.
        $this->course = $this->getDataGenerator()->create_course(array('enablecompletion' => true));
        $this->user = $this->getDataGenerator()->create_user();
        $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id);

        $this->module1 = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id));
        $this->module2 = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id));
    }

    /**
     * Asserts that two variables are equal.
     *
     * @param  mixed   $expected
     * @param  mixed   $actual
     * @param  string  $message
     * @param  float   $delta
     * @param  integer $maxDepth
     * @param  boolean $canonicalize
     * @param  boolean $ignoreCase
     */
    public static function assertEquals($expected, $actual, string $message = '', float $delta = 0, int $maxDepth = 10,
                                        bool $canonicalize = false, bool $ignoreCase = false): void {
        // Nasty cheating hack: prevent random failures on timemodified field.
        if (is_object($expected) and is_object($actual)) {
            if (property_exists($expected, 'timemodified') and property_exists($actual, 'timemodified')) {
                if ($expected->timemodified + 1 == $actual->timemodified) {
                    $expected = clone($expected);
                    $expected->timemodified = $actual->timemodified;
                }
            }
        }
        parent::assertEquals($expected, $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase);
    }

    public function test_is_enabled() {
        global $CFG;
        $this->mock_setup();

        // Config alone.
        $CFG->enablecompletion = COMPLETION_DISABLED;
        $this->assertEquals(COMPLETION_DISABLED, completion_info::is_enabled_for_site());
        $CFG->enablecompletion = COMPLETION_ENABLED;
        $this->assertEquals(COMPLETION_ENABLED, completion_info::is_enabled_for_site());

        // Course.
        $course = (object)array('id' =>13);
        $c = new completion_info($course);
        $course->enablecompletion = COMPLETION_DISABLED;
        $this->assertEquals(COMPLETION_DISABLED, $c->is_enabled());
        $course->enablecompletion = COMPLETION_ENABLED;
        $this->assertEquals(COMPLETION_ENABLED, $c->is_enabled());
        $CFG->enablecompletion = COMPLETION_DISABLED;
        $this->assertEquals(COMPLETION_DISABLED, $c->is_enabled());

        // Course and CM.
        $cm = new stdClass();
        $cm->completion = COMPLETION_TRACKING_MANUAL;
        $this->assertEquals(COMPLETION_DISABLED, $c->is_enabled($cm));
        $CFG->enablecompletion = COMPLETION_ENABLED;
        $course->enablecompletion = COMPLETION_DISABLED;
        $this->assertEquals(COMPLETION_DISABLED, $c->is_enabled($cm));
        $course->enablecompletion = COMPLETION_ENABLED;
        $this->assertEquals(COMPLETION_TRACKING_MANUAL, $c->is_enabled($cm));
        $cm->completion = COMPLETION_TRACKING_NONE;
        $this->assertEquals(COMPLETION_TRACKING_NONE, $c->is_enabled($cm));
        $cm->completion = COMPLETION_TRACKING_AUTOMATIC;
        $this->assertEquals(COMPLETION_TRACKING_AUTOMATIC, $c->is_enabled($cm));
    }

    public function test_update_state() {
        $this->mock_setup();

        $mockbuilder = $this->getMockBuilder('completion_info');
        $mockbuilder->setMethods(array('is_enabled', 'get_data', 'internal_get_state', 'internal_set_data',
                                       'user_can_override_completion'));
        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
        $c = $mockbuilder->getMock();
        $cm = (object)array('id'=>13, 'course'=>42);

        // Not enabled, should do nothing.
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(false));
        $c->update_state($cm);

        // Enabled, but current state is same as possible result, do nothing.
        $current = (object)array('completionstate' => COMPLETION_COMPLETE, 'overrideby' => null);
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1))
            ->method('get_data')
            ->with($cm, false, 0)
            ->will($this->returnValue($current));
        $c->update_state($cm, COMPLETION_COMPLETE);

        // Enabled, but current state is a specific one and new state is just
        // complete, so do nothing.
        $current->completionstate = COMPLETION_COMPLETE_PASS;
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1))
            ->method('get_data')
            ->with($cm, false, 0)
            ->will($this->returnValue($current));
        $c->update_state($cm, COMPLETION_COMPLETE);

        // Manual, change state (no change).
        $cm = (object)array('id'=>13, 'course'=>42, 'completion'=>COMPLETION_TRACKING_MANUAL);
        $current->completionstate=COMPLETION_COMPLETE;
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1))
            ->method('get_data')
            ->with($cm, false, 0)
            ->will($this->returnValue($current));
        $c->update_state($cm, COMPLETION_COMPLETE);

        // Manual, change state (change).
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1))
            ->method('get_data')
            ->with($cm, false, 0)
            ->will($this->returnValue($current));
        $changed = clone($current);
        $changed->timemodified = time();
        $changed->completionstate = COMPLETION_INCOMPLETE;
        $comparewith = new phpunit_constraint_object_is_equal_with_exceptions($changed);
        $comparewith->add_exception('timemodified', 'assertGreaterThanOrEqual');
        $c->expects($this->at(2))
            ->method('internal_set_data')
            ->with($cm, $comparewith);
        $c->update_state($cm, COMPLETION_INCOMPLETE);

        // Auto, change state.
        $cm = (object)array('id'=>13, 'course'=>42, 'completion'=>COMPLETION_TRACKING_AUTOMATIC);
        $current = (object)array('completionstate' => COMPLETION_COMPLETE, 'overrideby' => null);
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1))
            ->method('get_data')
            ->with($cm, false, 0)
            ->will($this->returnValue($current));
        $c->expects($this->at(2))
            ->method('internal_get_state')
            ->will($this->returnValue(COMPLETION_COMPLETE_PASS));
        $changed = clone($current);
        $changed->timemodified = time();
        $changed->completionstate = COMPLETION_COMPLETE_PASS;
        $comparewith = new phpunit_constraint_object_is_equal_with_exceptions($changed);
        $comparewith->add_exception('timemodified', 'assertGreaterThanOrEqual');
        $c->expects($this->at(3))
            ->method('internal_set_data')
            ->with($cm, $comparewith);
        $c->update_state($cm, COMPLETION_COMPLETE_PASS);

        // Manual tracking, change state by overriding it manually.
        $cm = (object)array('id' => 13, 'course' => 42, 'completion' => COMPLETION_TRACKING_MANUAL);
        $current = (object)array('completionstate' => COMPLETION_INCOMPLETE, 'overrideby' => null);
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1)) // Pretend the user has the required capability for overriding completion statuses.
            ->method('user_can_override_completion')
            ->will($this->returnValue(true));
        $c->expects($this->at(2))
            ->method('get_data')
            ->with($cm, false, 100)
            ->will($this->returnValue($current));
        $changed = clone($current);
        $changed->timemodified = time();
        $changed->completionstate = COMPLETION_COMPLETE;
        $changed->overrideby = 314159;
        $comparewith = new phpunit_constraint_object_is_equal_with_exceptions($changed);
        $comparewith->add_exception('timemodified', 'assertGreaterThanOrEqual');
        $c->expects($this->at(3))
            ->method('internal_set_data')
            ->with($cm, $comparewith);
        $c->update_state($cm, COMPLETION_COMPLETE, 100, true);
        // And confirm that the status can be changed back to incomplete without an override.
        $c->update_state($cm, COMPLETION_INCOMPLETE, 100);
        $c->expects($this->at(0))
            ->method('get_data')
            ->with($cm, false, 100)
            ->will($this->returnValue($current));
        $c->get_data($cm, false, 100);

        // Auto, change state via override, incomplete to complete.
        $cm = (object)array('id' => 13, 'course' => 42, 'completion' => COMPLETION_TRACKING_AUTOMATIC);
        $current = (object)array('completionstate' => COMPLETION_INCOMPLETE, 'overrideby' => null);
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1)) // Pretend the user has the required capability for overriding completion statuses.
            ->method('user_can_override_completion')
            ->will($this->returnValue(true));
        $c->expects($this->at(2))
            ->method('get_data')
            ->with($cm, false, 100)
            ->will($this->returnValue($current));
        $changed = clone($current);
        $changed->timemodified = time();
        $changed->completionstate = COMPLETION_COMPLETE;
        $changed->overrideby = 314159;
        $comparewith = new phpunit_constraint_object_is_equal_with_exceptions($changed);
        $comparewith->add_exception('timemodified', 'assertGreaterThanOrEqual');
        $c->expects($this->at(3))
            ->method('internal_set_data')
            ->with($cm, $comparewith);
        $c->update_state($cm, COMPLETION_COMPLETE, 100, true);
        $c->expects($this->at(0))
            ->method('get_data')
            ->with($cm, false, 100)
            ->will($this->returnValue($changed));
        $c->get_data($cm, false, 100);

        // Now confirm that the status cannot be changed back to incomplete without an override.
        // I.e. test that automatic completion won't trigger a change back to COMPLETION_INCOMPLETE when overridden.
        $c->update_state($cm, COMPLETION_INCOMPLETE, 100);
        $c->expects($this->at(0))
            ->method('get_data')
            ->with($cm, false, 100)
            ->will($this->returnValue($changed));
        $c->get_data($cm, false, 100);

        // Now confirm the status can be changed back from complete to incomplete using an override.
        $cm = (object)array('id' => 13, 'course' => 42, 'completion' => COMPLETION_TRACKING_AUTOMATIC);
        $current = (object)array('completionstate' => COMPLETION_COMPLETE, 'overrideby' => 2);
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1)) // Pretend the user has the required capability for overriding completion statuses.
        ->method('user_can_override_completion')
            ->will($this->returnValue(true));
        $c->expects($this->at(2))
            ->method('get_data')
            ->with($cm, false, 100)
            ->will($this->returnValue($current));
        $changed = clone($current);
        $changed->timemodified = time();
        $changed->completionstate = COMPLETION_INCOMPLETE;
        $changed->overrideby = 314159;
        $comparewith = new phpunit_constraint_object_is_equal_with_exceptions($changed);
        $comparewith->add_exception('timemodified', 'assertGreaterThanOrEqual');
        $c->expects($this->at(3))
            ->method('internal_set_data')
            ->with($cm, $comparewith);
        $c->update_state($cm, COMPLETION_INCOMPLETE, 100, true);
        $c->expects($this->at(0))
            ->method('get_data')
            ->with($cm, false, 100)
            ->will($this->returnValue($changed));
        $c->get_data($cm, false, 100);
    }

    public function test_internal_get_state() {
        global $DB;
        $this->mock_setup();

        $mockbuilder = $this->getMockBuilder('completion_info');
        $mockbuilder->setMethods(array('internal_get_grade_state'));
        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
        $c = $mockbuilder->getMock();

        $cm = (object)array('id'=>13, 'course'=>42, 'completiongradeitemnumber'=>null);

        // If view is required, but they haven't viewed it yet.
        $cm->completionview = COMPLETION_VIEW_REQUIRED;
        $current = (object)array('viewed'=>COMPLETION_NOT_VIEWED);
        $this->assertEquals(COMPLETION_INCOMPLETE, $c->internal_get_state($cm, 123, $current));

        // OK set view not required.
        $cm->completionview = COMPLETION_VIEW_NOT_REQUIRED;

        // Test not getting module name.
        $cm->modname='label';
        $this->assertEquals(COMPLETION_COMPLETE, $c->internal_get_state($cm, 123, $current));

        // Test getting module name.
        $cm->module = 13;
        unset($cm->modname);
        /** @var $DB PHPUnit_Framework_MockObject_MockObject */
        $DB->expects($this->once())
            ->method('get_field')
            ->with('modules', 'name', array('id'=>13))
            ->will($this->returnValue('lable'));
        $this->assertEquals(COMPLETION_COMPLETE, $c->internal_get_state($cm, 123, $current));

        // Note: This function is not fully tested (including kind of the main part) because:
        // * the grade_item/grade_grade calls are static and can't be mocked,
        // * the plugin_supports call is static and can't be mocked.
    }

    public function test_set_module_viewed() {
        $this->mock_setup();

        $mockbuilder = $this->getMockBuilder('completion_info');
        $mockbuilder->setMethods(array('is_enabled', 'get_data', 'internal_set_data', 'update_state'));
        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
        $c = $mockbuilder->getMock();
        $cm = (object)array('id'=>13, 'course'=>42);

        // Not tracking completion, should do nothing.
        $cm->completionview = COMPLETION_VIEW_NOT_REQUIRED;
        $c->set_module_viewed($cm);

        // Tracking completion but completion is disabled, should do nothing.
        $cm->completionview = COMPLETION_VIEW_REQUIRED;
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(false));
        $c->set_module_viewed($cm);

        // Now it's enabled, we expect it to get data. If data already has
        // viewed, still do nothing.
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1))
            ->method('get_data')
            ->with($cm, 0)
            ->will($this->returnValue((object)array('viewed'=>COMPLETION_VIEWED)));
        $c->set_module_viewed($cm);

        // OK finally one that hasn't been viewed, now it should set it viewed
        // and update state.
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1))
            ->method('get_data')
            ->with($cm, false, 1337)
            ->will($this->returnValue((object)array('viewed'=>COMPLETION_NOT_VIEWED)));
        $c->expects($this->at(2))
            ->method('internal_set_data')
            ->with($cm, (object)array('viewed'=>COMPLETION_VIEWED));
        $c->expects($this->at(3))
            ->method('update_state')
            ->with($cm, COMPLETION_COMPLETE, 1337);
        $c->set_module_viewed($cm, 1337);
    }

    public function test_count_user_data() {
        global $DB;
        $this->mock_setup();

        $course = (object)array('id'=>13);
        $cm = (object)array('id'=>42);

        /** @var $DB PHPUnit_Framework_MockObject_MockObject */
        $DB->expects($this->at(0))
            ->method('get_field_sql')
            ->will($this->returnValue(666));

        $c = new completion_info($course);
        $this->assertEquals(666, $c->count_user_data($cm));
    }

    public function test_delete_all_state() {
        global $DB;
        $this->mock_setup();

        $course = (object)array('id'=>13);
        $cm = (object)array('id'=>42, 'course'=>13);
        $c = new completion_info($course);

        // Check it works ok without data in session.
        /** @var $DB PHPUnit_Framework_MockObject_MockObject */
        $DB->expects($this->at(0))
            ->method('delete_records')
            ->with('course_modules_completion', array('coursemoduleid'=>42))
            ->will($this->returnValue(true));
        $c->delete_all_state($cm);
    }

    public function test_reset_all_state() {
        global $DB;
        $this->mock_setup();

        $mockbuilder = $this->getMockBuilder('completion_info');
        $mockbuilder->setMethods(array('delete_all_state', 'get_tracked_users', 'update_state'));
        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
        $c = $mockbuilder->getMock();

        $cm = (object)array('id'=>13, 'course'=>42, 'completion'=>COMPLETION_TRACKING_AUTOMATIC);

        /** @var $DB PHPUnit_Framework_MockObject_MockObject */
        $DB->expects($this->at(0))
            ->method('get_recordset')
            ->will($this->returnValue(
                new core_completionlib_fake_recordset(array((object)array('id'=>1, 'userid'=>100), (object)array('id'=>2, 'userid'=>101)))));

        $c->expects($this->at(0))
            ->method('delete_all_state')
            ->with($cm);

        $c->expects($this->at(1))
            ->method('get_tracked_users')
            ->will($this->returnValue(array(
            (object)array('id'=>100, 'firstname'=>'Woot', 'lastname'=>'Plugh'),
            (object)array('id'=>201, 'firstname'=>'Vroom', 'lastname'=>'Xyzzy'))));

        $c->expects($this->at(2))
            ->method('update_state')
            ->with($cm, COMPLETION_UNKNOWN, 100);
        $c->expects($this->at(3))
            ->method('update_state')
            ->with($cm, COMPLETION_UNKNOWN, 101);
        $c->expects($this->at(4))
            ->method('update_state')
            ->with($cm, COMPLETION_UNKNOWN, 201);

        $c->reset_all_state($cm);
    }

    public function test_get_data() {
        global $DB;
        $this->mock_setup();

        $cache = cache::make('core', 'completion');

        $c = new completion_info((object)array('id'=>42, 'cacherev'=>1));
        $cm = (object)array('id'=>13, 'course'=>42);

        // 1. Not current user, record exists.
        $sillyrecord = (object)array('frog'=>'kermit');

        /** @var $DB PHPUnit_Framework_MockObject_MockObject */
        $DB->expects($this->at(0))
            ->method('get_record')
            ->with('course_modules_completion', array('coursemoduleid'=>13, 'userid'=>123))
            ->will($this->returnValue($sillyrecord));
        $result = $c->get_data($cm, false, 123);
        $this->assertEquals($sillyrecord, $result);
        $this->assertEquals(false, $cache->get('123_42')); // Not current user is not cached.

        // 2. Not current user, default record, whole course.
        $cache->purge();
        $DB->expects($this->at(0))
            ->method('get_records_sql')
            ->will($this->returnValue(array()));
        $modinfo = new stdClass();
        $modinfo->cms = array((object)array('id'=>13));
        $result=$c->get_data($cm, true, 123, $modinfo);
        $this->assertEquals((object)array(
            'id' => '0', 'coursemoduleid' => 13, 'userid' => 123, 'completionstate' => 0,
            'viewed' => 0, 'timemodified' => 0, 'overrideby' => 0), $result);
        $this->assertEquals(false, $cache->get('123_42')); // Not current user is not cached.

        // 3. Current user, single record, not from cache.
        $DB->expects($this->at(0))
            ->method('get_record')
            ->with('course_modules_completion', array('coursemoduleid'=>13, 'userid'=>314159))
            ->will($this->returnValue($sillyrecord));
        $result = $c->get_data($cm);
        $this->assertEquals($sillyrecord, $result);
        $cachevalue = $cache->get('314159_42');
        $this->assertEquals((array)$sillyrecord, $cachevalue[13]);

        // 4. Current user, 'whole course', but from cache.
        $result = $c->get_data($cm, true);
        $this->assertEquals($sillyrecord, $result);

        // 5. Current user, 'whole course' and record not in cache.
        $cache->purge();

        // Scenario: Completion data exists for one CMid.
        $basicrecord = (object)array('coursemoduleid'=>13);
        $DB->expects($this->at(0))
            ->method('get_records_sql')
            ->will($this->returnValue(array('1'=>$basicrecord)));

        // There are two CMids in total, the one we had data for and another one.
        $modinfo = new stdClass();
        $modinfo->cms = array((object)array('id'=>13), (object)array('id'=>14));
        $result = $c->get_data($cm, true, 0, $modinfo);

        // Check result.
        $this->assertEquals($basicrecord, $result);

        // Check the cache contents.
        $cachevalue = $cache->get('314159_42');
        $this->assertEquals($basicrecord, (object)$cachevalue[13]);
        $this->assertEquals(array('id' => '0', 'coursemoduleid' => 14,
            'userid' => 314159, 'completionstate' => 0, 'viewed' => 0, 'overrideby' => 0, 'timemodified' => 0),
            $cachevalue[14]);
    }

    public function test_internal_set_data() {
        global $DB;
        $this->setup_data();

        $this->setUser($this->user);
        $completionauto = array('completion' => COMPLETION_TRACKING_AUTOMATIC);
        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id), $completionauto);
        $cm = get_coursemodule_from_instance('forum', $forum->id);
        $c = new completion_info($this->course);

        // 1) Test with new data.
        $data = new stdClass();
        $data->id = 0;
        $data->userid = $this->user->id;
        $data->coursemoduleid = $cm->id;
        $data->completionstate = COMPLETION_COMPLETE;
        $data->timemodified = time();
        $data->viewed = COMPLETION_NOT_VIEWED;
        $data->overrideby = null;

        $c->internal_set_data($cm, $data);
        $d1 = $DB->get_field('course_modules_completion', 'id', array('coursemoduleid' => $cm->id));
        $this->assertEquals($d1, $data->id);
        $cache = cache::make('core', 'completion');
        // Cache was not set for another user.
        $this->assertEquals(array('cacherev' => $this->course->cacherev, $cm->id => $data),
            $cache->get($data->userid . '_' . $cm->course));

        // 2) Test with existing data and for different user.
        $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id), $completionauto);
        $cm2 = get_coursemodule_from_instance('forum', $forum2->id);
        $newuser = $this->getDataGenerator()->create_user();

        $d2 = new stdClass();
        $d2->id = 7;
        $d2->userid = $newuser->id;
        $d2->coursemoduleid = $cm2->id;
        $d2->completionstate = COMPLETION_COMPLETE;
        $d2->timemodified = time();
        $d2->viewed = COMPLETION_NOT_VIEWED;
        $d2->overrideby = null;
        $c->internal_set_data($cm2, $d2);
        // Cache for current user returns the data.
        $cachevalue = $cache->get($data->userid . '_' . $cm->course);
        $this->assertEquals($data, $cachevalue[$cm->id]);
        // Cache for another user is not filled.
        $this->assertEquals(false, $cache->get($d2->userid . '_' . $cm2->course));

        // 3) Test where it THINKS the data is new (from cache) but actually
        //    in the database it has been set since.
        // 1) Test with new data.
        $forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id), $completionauto);
        $cm3 = get_coursemodule_from_instance('forum', $forum3->id);
        $newuser2 = $this->getDataGenerator()->create_user();
        $d3 = new stdClass();
        $d3->id = 13;
        $d3->userid = $newuser2->id;
        $d3->coursemoduleid = $cm3->id;
        $d3->completionstate = COMPLETION_COMPLETE;
        $d3->timemodified = time();
        $d3->viewed = COMPLETION_NOT_VIEWED;
        $d3->overrideby = null;
        $DB->insert_record('course_modules_completion', $d3);
        $c->internal_set_data($cm, $data);
    }

    public function test_get_progress_all() {
        global $DB;
        $this->mock_setup();

        $mockbuilder = $this->getMockBuilder('completion_info');
        $mockbuilder->setMethods(array('get_tracked_users'));
        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
        $c = $mockbuilder->getMock();

        // 1) Basic usage.
        $c->expects($this->at(0))
            ->method('get_tracked_users')
            ->with(false,  array(),  0,  '',  '',  '',  null)
            ->will($this->returnValue(array(
                (object)array('id'=>100, 'firstname'=>'Woot', 'lastname'=>'Plugh'),
                (object)array('id'=>201, 'firstname'=>'Vroom', 'lastname'=>'Xyzzy'))));
        $DB->expects($this->at(0))
            ->method('get_in_or_equal')
            ->with(array(100, 201))
            ->will($this->returnValue(array(' IN (100, 201)', array())));
        $progress1 = (object)array('userid'=>100, 'coursemoduleid'=>13);
        $progress2 = (object)array('userid'=>201, 'coursemoduleid'=>14);
        $DB->expects($this->at(1))
            ->method('get_recordset_sql')
            ->will($this->returnValue(new core_completionlib_fake_recordset(array($progress1, $progress2))));

        $this->assertEquals(array(
                100 => (object)array('id'=>100, 'firstname'=>'Woot', 'lastname'=>'Plugh',
                    'progress'=>array(13=>$progress1)),
                201 => (object)array('id'=>201, 'firstname'=>'Vroom', 'lastname'=>'Xyzzy',
                    'progress'=>array(14=>$progress2)),
            ), $c->get_progress_all(false));

        // 2) With more than 1, 000 results.
        $tracked = array();
        $ids = array();
        $progress = array();
        for ($i = 100; $i<2000; $i++) {
            $tracked[] = (object)array('id'=>$i, 'firstname'=>'frog', 'lastname'=>$i);
            $ids[] = $i;
            $progress[] = (object)array('userid'=>$i, 'coursemoduleid'=>13);
            $progress[] = (object)array('userid'=>$i, 'coursemoduleid'=>14);
        }
        $c->expects($this->at(0))
            ->method('get_tracked_users')
            ->with(true,  3,  0,  '',  '',  '',  null)
            ->will($this->returnValue($tracked));
        $DB->expects($this->at(0))
            ->method('get_in_or_equal')
            ->with(array_slice($ids, 0, 1000))
            ->will($this->returnValue(array(' IN whatever', array())));
        $DB->expects($this->at(1))
            ->method('get_recordset_sql')
            ->will($this->returnValue(new core_completionlib_fake_recordset(array_slice($progress, 0, 1000))));

        $DB->expects($this->at(2))
            ->method('get_in_or_equal')
            ->with(array_slice($ids, 1000))
            ->will($this->returnValue(array(' IN whatever2', array())));
        $DB->expects($this->at(3))
            ->method('get_recordset_sql')
            ->will($this->returnValue(new core_completionlib_fake_recordset(array_slice($progress, 1000))));

        $result = $c->get_progress_all(true, 3);
        $resultok = true;
        $resultok  =  $resultok && ($ids == array_keys($result));

        foreach ($result as $userid => $data) {
            $resultok  =  $resultok && $data->firstname == 'frog';
            $resultok  =  $resultok && $data->lastname == $userid;
            $resultok  =  $resultok && $data->id == $userid;
            $cms = $data->progress;
            $resultok =  $resultok && (array(13, 14) == array_keys($cms));
            $resultok =  $resultok && ((object)array('userid'=>$userid, 'coursemoduleid'=>13) == $cms[13]);
            $resultok =  $resultok && ((object)array('userid'=>$userid, 'coursemoduleid'=>14) == $cms[14]);
        }
        $this->assertTrue($resultok);
    }

    public function test_inform_grade_changed() {
        $this->mock_setup();

        $mockbuilder = $this->getMockBuilder('completion_info');
        $mockbuilder->setMethods(array('is_enabled', 'update_state'));
        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
        $c = $mockbuilder->getMock();

        $cm = (object)array('course'=>42, 'id'=>13, 'completion'=>0, 'completiongradeitemnumber'=>null);
        $item = (object)array('itemnumber'=>3,  'gradepass'=>1,  'hidden'=>0);
        $grade = (object)array('userid'=>31337,  'finalgrade'=>0,  'rawgrade'=>0);

        // Not enabled (should do nothing).
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(false));
        $c->inform_grade_changed($cm, $item, $grade, false);

        // Enabled but still no grade completion required,  should still do nothing.
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->inform_grade_changed($cm, $item, $grade, false);

        // Enabled and completion required but item number is wrong,  does nothing.
        $cm = (object)array('course'=>42, 'id'=>13, 'completion'=>0, 'completiongradeitemnumber'=>7);
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->inform_grade_changed($cm, $item, $grade, false);

        // Enabled and completion required and item number right. It is supposed
        // to call update_state with the new potential state being obtained from
        // internal_get_grade_state.
        $cm = (object)array('course'=>42, 'id'=>13, 'completion'=>0, 'completiongradeitemnumber'=>3);
        $grade = (object)array('userid'=>31337,  'finalgrade'=>1,  'rawgrade'=>0);
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1))
            ->method('update_state')
            ->with($cm, COMPLETION_COMPLETE_PASS, 31337)
            ->will($this->returnValue(true));
        $c->inform_grade_changed($cm, $item, $grade, false);

        // Same as above but marked deleted. It is supposed to call update_state
        // with new potential state being COMPLETION_INCOMPLETE.
        $cm = (object)array('course'=>42, 'id'=>13, 'completion'=>0, 'completiongradeitemnumber'=>3);
        $grade = (object)array('userid'=>31337,  'finalgrade'=>1,  'rawgrade'=>0);
        $c->expects($this->at(0))
            ->method('is_enabled')
            ->with($cm)
            ->will($this->returnValue(true));
        $c->expects($this->at(1))
            ->method('update_state')
            ->with($cm, COMPLETION_INCOMPLETE, 31337)
            ->will($this->returnValue(true));
        $c->inform_grade_changed($cm, $item, $grade, true);
    }

    public function test_internal_get_grade_state() {
        $this->mock_setup();

        $item = new stdClass;
        $grade = new stdClass;

        $item->gradepass = 4;
        $item->hidden = 0;
        $grade->rawgrade = 4.0;
        $grade->finalgrade = null;

        // Grade has pass mark and is not hidden,  user passes.
        $this->assertEquals(
            COMPLETION_COMPLETE_PASS,
            completion_info::internal_get_grade_state($item, $grade));

        // Same but user fails.
        $grade->rawgrade = 3.9;
        $this->assertEquals(
            COMPLETION_COMPLETE_FAIL,
            completion_info::internal_get_grade_state($item, $grade));

        // User fails on raw grade but passes on final.
        $grade->finalgrade = 4.0;
        $this->assertEquals(
            COMPLETION_COMPLETE_PASS,
            completion_info::internal_get_grade_state($item, $grade));

        // Item is hidden.
        $item->hidden = 1;
        $this->assertEquals(
            COMPLETION_COMPLETE,
            completion_info::internal_get_grade_state($item, $grade));

        // Item isn't hidden but has no pass mark.
        $item->hidden = 0;
        $item->gradepass = 0;
        $this->assertEquals(
            COMPLETION_COMPLETE,
            completion_info::internal_get_grade_state($item, $grade));
    }

    public function test_get_activities() {
        global $CFG;
        $this->resetAfterTest();

        // Enable completion before creating modules, otherwise the completion data is not written in DB.
        $CFG->enablecompletion = true;

        // Create a course with mixed auto completion data.
        $course = $this->getDataGenerator()->create_course(array('enablecompletion' => true));
        $completionauto = array('completion' => COMPLETION_TRACKING_AUTOMATIC);
        $completionmanual = array('completion' => COMPLETION_TRACKING_MANUAL);
        $completionnone = array('completion' => COMPLETION_TRACKING_NONE);
        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), $completionauto);
        $page = $this->getDataGenerator()->create_module('page', array('course' => $course->id), $completionauto);
        $data = $this->getDataGenerator()->create_module('data', array('course' => $course->id), $completionmanual);

        $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), $completionnone);
        $page2 = $this->getDataGenerator()->create_module('page', array('course' => $course->id), $completionnone);
        $data2 = $this->getDataGenerator()->create_module('data', array('course' => $course->id), $completionnone);

        // Create data in another course to make sure it's not considered.
        $course2 = $this->getDataGenerator()->create_course(array('enablecompletion' => true));
        $c2forum = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id), $completionauto);
        $c2page = $this->getDataGenerator()->create_module('page', array('course' => $course2->id), $completionmanual);
        $c2data = $this->getDataGenerator()->create_module('data', array('course' => $course2->id), $completionnone);

        $c = new completion_info($course);
        $activities = $c->get_activities();
        $this->assertCount(3, $activities);
        $this->assertTrue(isset($activities[$forum->cmid]));
        $this->assertSame($forum->name, $activities[$forum->cmid]->name);
        $this->assertTrue(isset($activities[$page->cmid]));
        $this->assertSame($page->name, $activities[$page->cmid]->name);
        $this->assertTrue(isset($activities[$data->cmid]));
        $this->assertSame($data->name, $activities[$data->cmid]->name);

        $this->assertFalse(isset($activities[$forum2->cmid]));
        $this->assertFalse(isset($activities[$page2->cmid]));
        $this->assertFalse(isset($activities[$data2->cmid]));
    }

    public function test_has_activities() {
        global $CFG;
        $this->resetAfterTest();

        // Enable completion before creating modules, otherwise the completion data is not written in DB.
        $CFG->enablecompletion = true;

        // Create a course with mixed auto completion data.
        $course = $this->getDataGenerator()->create_course(array('enablecompletion' => true));
        $course2 = $this->getDataGenerator()->create_course(array('enablecompletion' => true));
        $completionauto = array('completion' => COMPLETION_TRACKING_AUTOMATIC);
        $completionnone = array('completion' => COMPLETION_TRACKING_NONE);
        $c1forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), $completionauto);
        $c2forum = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id), $completionnone);

        $c1 = new completion_info($course);
        $c2 = new completion_info($course2);

        $this->assertTrue($c1->has_activities());
        $this->assertFalse($c2->has_activities());
    }

    /**
     * Test that data is cleaned up when we delete courses that are set as completion criteria for other courses
     *
     * @return void
     */
    public function test_course_delete_prerequisite() {
        global $DB;

        $this->setup_data();

        $courseprerequisite = $this->getDataGenerator()->create_course(['enablecompletion' => true]);

        $criteriadata = (object) [
            'id' => $this->course->id,
            'criteria_course' => [$courseprerequisite->id],
        ];

        /** @var completion_criteria_course $criteria */
        $criteria = completion_criteria::factory(['criteriatype' => COMPLETION_CRITERIA_TYPE_COURSE]);
        $criteria->update_config($criteriadata);

        // Sanity test.
        $this->assertTrue($DB->record_exists('course_completion_criteria', [
            'course' => $this->course->id,
            'criteriatype' => COMPLETION_CRITERIA_TYPE_COURSE,
            'courseinstance' => $courseprerequisite->id,
        ]));

        // Deleting the prerequisite course should remove the completion criteria.
        delete_course($courseprerequisite, false);

        $this->assertFalse($DB->record_exists('course_completion_criteria', [
            'course' => $this->course->id,
            'criteriatype' => COMPLETION_CRITERIA_TYPE_COURSE,
            'courseinstance' => $courseprerequisite->id,
        ]));
    }

    /**
     * Test course module completion update event.
     */
    public function test_course_module_completion_updated_event() {
        global $USER, $CFG;

        $this->setup_data();

        $this->setAdminUser();

        $completionauto = array('completion' => COMPLETION_TRACKING_AUTOMATIC);
        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id), $completionauto);

        $c = new completion_info($this->course);
        $activities = $c->get_activities();
        $this->assertEquals(1, count($activities));
        $this->assertTrue(isset($activities[$forum->cmid]));
        $this->assertEquals($activities[$forum->cmid]->name, $forum->name);

        $current = $c->get_data($activities[$forum->cmid], false, $this->user->id);
        $current->completionstate = COMPLETION_COMPLETE;
        $current->timemodified = time();
        $sink = $this->redirectEvents();
        $c->internal_set_data($activities[$forum->cmid], $current);
        $events = $sink->get_events();
        $event = reset($events);
        $this->assertInstanceOf('\core\event\course_module_completion_updated', $event);
        $this->assertEquals($forum->cmid, $event->get_record_snapshot('course_modules_completion', $event->objectid)->coursemoduleid);
        $this->assertEquals($current, $event->get_record_snapshot('course_modules_completion', $event->objectid));
        $this->assertEquals(context_module::instance($forum->cmid), $event->get_context());
        $this->assertEquals($USER->id, $event->userid);
        $this->assertEquals($this->user->id, $event->relateduserid);
        $this->assertInstanceOf('moodle_url', $event->get_url());
        $this->assertEventLegacyData($current, $event);
    }

    /**
     * Test course completed event.
     */
    public function test_course_completed_event() {
        global $USER;

        $this->setup_data();
        $this->setAdminUser();

        $completionauto = array('completion' => COMPLETION_TRACKING_AUTOMATIC);
        $ccompletion = new completion_completion(array('course' => $this->course->id, 'userid' => $this->user->id));

        // Mark course as complete and get triggered event.
        $sink = $this->redirectEvents();
        $ccompletion->mark_complete();
        $events = $sink->get_events();
        $event = reset($events);

        $this->assertInstanceOf('\core\event\course_completed', $event);
        $this->assertEquals($this->course->id, $event->get_record_snapshot('course_completions', $event->objectid)->course);
        $this->assertEquals($this->course->id, $event->courseid);
        $this->assertEquals($USER->id, $event->userid);
        $this->assertEquals($this->user->id, $event->relateduserid);
        $this->assertEquals(context_course::instance($this->course->id), $event->get_context());
        $this->assertInstanceOf('moodle_url', $event->get_url());
        $data = $ccompletion->get_record_data();
        $this->assertEventLegacyData($data, $event);
    }

    /**
     * Test course completed event.
     */
    public function test_course_completion_updated_event() {
        $this->setup_data();
        $coursecontext = context_course::instance($this->course->id);
        $coursecompletionevent = \core\event\course_completion_updated::create(
                array(
                    'courseid' => $this->course->id,
                    'context' => $coursecontext
                    )
                );

        // Mark course as complete and get triggered event.
        $sink = $this->redirectEvents();
        $coursecompletionevent->trigger();
        $events = $sink->get_events();
        $event = array_pop($events);
        $sink->close();

        $this->assertInstanceOf('\core\event\course_completion_updated', $event);
        $this->assertEquals($this->course->id, $event->courseid);
        $this->assertEquals($coursecontext, $event->get_context());
        $this->assertInstanceOf('moodle_url', $event->get_url());
        $expectedlegacylog = array($this->course->id, 'course', 'completion updated', 'completion.php?id='.$this->course->id);
        $this->assertEventLegacyLogData($expectedlegacylog, $event);
    }

    public function test_completion_can_view_data() {
        $this->setup_data();

        $student = $this->getDataGenerator()->create_user();
        $this->getDataGenerator()->enrol_user($student->id, $this->course->id);

        $this->setUser($student);
        $this->assertTrue(completion_can_view_data($student->id, $this->course->id));
        $this->assertFalse(completion_can_view_data($this->user->id, $this->course->id));
    }
}

class core_completionlib_fake_recordset implements Iterator {
    protected $closed;
    protected $values, $index;

    public function __construct($values) {
        $this->values = $values;
        $this->index = 0;
    }

    public function current() {
        return $this->values[$this->index];
    }

    public function key() {
        return $this->values[$this->index];
    }

    public function next() {
        $this->index++;
    }

    public function rewind() {
        $this->index = 0;
    }

    public function valid() {
        return count($this->values) > $this->index;
    }

    public function close() {
        $this->closed = true;
    }

    public function was_closed() {
        return $this->closed;
    }
}
