From d938c0f299b8aee2a02fc6b6a76aa5e5ea002f59 Mon Sep 17 00:00:00 2001 From: Michael Hawkins Date: Wed, 24 Mar 2021 02:37:38 +0800 Subject: [PATCH] MDL-70935 mod_scorm: Custom completion implementation --- .../classes/completion/custom_completion.php | 205 ++++++++++ mod/scorm/lang/en/scorm.php | 5 + mod/scorm/tests/custom_completion_test.php | 370 ++++++++++++++++++ 3 files changed, 580 insertions(+) create mode 100644 mod/scorm/classes/completion/custom_completion.php create mode 100644 mod/scorm/tests/custom_completion_test.php diff --git a/mod/scorm/classes/completion/custom_completion.php b/mod/scorm/classes/completion/custom_completion.php new file mode 100644 index 00000000000..aae539bbe59 --- /dev/null +++ b/mod/scorm/classes/completion/custom_completion.php @@ -0,0 +1,205 @@ +. + +declare(strict_types=1); + +namespace mod_scorm\completion; + +defined('MOODLE_INTERNAL') || die(); + +use core_completion\activity_custom_completion; + +require_once($CFG->dirroot.'/mod/scorm/locallib.php'); + +/** + * Activity custom completion subclass for the scorm activity. + * + * Contains the class for defining mod_scorm's custom completion rules + * and fetching a scorm instance's completion statuses for a user. + * + * @package mod_scorm + * @copyright Michael Hawkins + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_completion extends activity_custom_completion { + + /** + * Fetches the completion state for a given completion rule. + * + * @param string $rule The completion rule. + * @return int The completion state. + */ + public function get_state(string $rule): int { + global $DB; + + $this->validate_rule($rule); + + // Base query used when fetching user's tracks data. + $basequery = "SELECT id, scoid, element, value + FROM {scorm_scoes_track} + WHERE scormid = ? + AND userid = ?"; + + switch ($rule) { + case 'completionstatusrequired': + $status = COMPLETION_INCOMPLETE; + $query = $basequery . + " AND element IN ( + 'cmi.core.lesson_status', + 'cmi.completion_status', + 'cmi.success_status' + )"; + + $tracks = $DB->get_records_sql($query, [$this->cm->instance, $this->userid]); + + // Get available status list. + $statuses = array_flip(\scorm_status_options()); + $statusbits = 0; + + $requiredcompletionstatusid = $this->cm->customdata['customcompletionrules']['completionstatusrequired'] ?? 0; + + // Check at least one track meets the required completion status value(s). + foreach ($tracks as $track) { + if (array_key_exists($track->value, $statuses)) { + $statusbits |= $statuses[$track->value]; + } + + // All completion status requirements met. + if ($statusbits == $requiredcompletionstatusid) { + $status = COMPLETION_COMPLETE; + break; + } + } + + break; + + case 'completionscorerequired': + $status = COMPLETION_INCOMPLETE; + $query = $basequery . + " AND element IN ( + 'cmi.core.score.raw', + 'cmi.score.raw' + )"; + + $tracks = $DB->get_records_sql($query, [$this->cm->instance, $this->userid]); + + $requiredscore = $this->cm->customdata['customcompletionrules']['completionscorerequired']; + + // Check if any track meets or exceeds the minimum score required. + foreach ($tracks as $track) { + if (strlen($track->value) && (floatval($track->value) >= $requiredscore)) { + $status = COMPLETION_COMPLETE; + + // No need to check any other tracks once condition is confirmed completed. + break; + } + } + + break; + + case 'completionstatusallscos': + // Assume complete unless we find a sco that is not complete. + $status = COMPLETION_COMPLETE; + $query = $basequery . + " AND element IN ( + 'cmi.core.lesson_status', + 'cmi.completion_status', + 'cmi.success_status' + )"; + + $tracks = $DB->get_records_sql($query, [$this->cm->instance, $this->userid]); + + // Get available status list. + $statuses = array_flip(\scorm_status_options()); + + // Make a list of all sco IDs. + $scoids = []; + foreach ($tracks as $track) { + if (array_key_exists($track->value, $statuses)) { + $scoids[] = $track->scoid; + } + } + + // Iterate over all scos and ensure each has a lesson_status. + $scos = $DB->get_records('scorm_scoes', ['scorm' => $this->cm->instance, 'scormtype' => 'sco']); + + foreach ($scos as $sco) { + // If we find a sco without a lesson status, this condition is not completed. + if (!in_array($sco->id, $scoids)) { + $status = COMPLETION_INCOMPLETE; + } + } + + break; + + default: + $status = COMPLETION_INCOMPLETE; + break; + } + + // If not yet meeting the requirement and no attempts remain to complete it, mark it as failed. + if ($status === COMPLETION_INCOMPLETE) { + $scorm = $DB->get_record('scorm', ['id' => $this->cm->instance]); + $attemptcount = scorm_get_attempt_count($this->userid, $scorm); + + if ($scorm->maxattempt > 0 && $attemptcount >= $scorm->maxattempt) { + $status = COMPLETION_COMPLETE_FAIL; + } + } + + return $status; + } + + /** + * Fetch the list of custom completion rules that this module defines. + * + * @return array + */ + public static function get_defined_custom_rules(): array { + return [ + 'completionstatusrequired', + 'completionscorerequired', + 'completionstatusallscos', + ]; + } + + /** + * Returns an associative array of the descriptions of custom completion rules. + * + * @return array + */ + public function get_custom_rule_descriptions(): array { + $scorerequired = $this->cm->customdata['customcompletionrules']['completionscorerequired'] ?? 0; + + // Prepare completion status requirements string. + $statusstrings = \scorm_status_options(); + $completionstatusid = $this->cm->customdata['customcompletionrules']['completionstatusrequired'] ?? 0; + + if (array_key_exists($completionstatusid, $statusstrings)) { + // Single status required. + $statusrequired = $statusstrings[$completionstatusid]; + } else { + // All statuses required. + $statusrequired = 'completedandpassed'; + } + + return [ + 'completionstatusrequired' => get_string("completiondetail:completionstatus{$statusrequired}", 'scorm'), + 'completionscorerequired' => get_string('completiondetail:completionscore', 'scorm', $scorerequired), + 'completionstatusallscos' => get_string('completiondetail:allscos', 'scorm'), + ]; + } +} diff --git a/mod/scorm/lang/en/scorm.php b/mod/scorm/lang/en/scorm.php index b878579762c..f5dd2d22809 100644 --- a/mod/scorm/lang/en/scorm.php +++ b/mod/scorm/lang/en/scorm.php @@ -81,6 +81,11 @@ $string['collapsetocwinsize'] = 'Collapse TOC when window size below'; $string['collapsetocwinsizedesc'] = 'This setting lets you specify the window size below which the TOC should automatically collapse.'; $string['compatibilitysettings'] = 'Compatibility settings'; $string['completed'] = 'Completed'; +$string['completiondetail:completionstatuspassed'] = 'Pass the activity'; +$string['completiondetail:completionstatuscompleted'] = 'Complete the activity'; +$string['completiondetail:completionstatuscompletedandpassed'] = 'Complete and pass the activity'; +$string['completiondetail:completionscore'] = 'Receive a score of {$a} or more'; +$string['completiondetail:allscos'] = 'Do all parts of this activity'; $string['completionscorerequired'] = 'Require minimum score'; $string['completionscorerequireddesc'] = 'Minimum score of {$a} is required for completion'; $string['completionscorerequired_help'] = 'Enabling this setting will require a user to have at least the minimum score entered to be marked complete in this SCORM activity, as well as any other Activity Completion requirements.'; diff --git a/mod/scorm/tests/custom_completion_test.php b/mod/scorm/tests/custom_completion_test.php new file mode 100644 index 00000000000..4f3b4a18a03 --- /dev/null +++ b/mod/scorm/tests/custom_completion_test.php @@ -0,0 +1,370 @@ +. + +declare(strict_types = 1); + +namespace mod_scorm; + +use advanced_testcase; +use cm_info; +use coding_exception; +use mod_scorm\completion\custom_completion; +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/completionlib.php'); +require_once($CFG->dirroot.'/mod/scorm/locallib.php'); + +/** + * Class for unit testing mod_scorm/custom_completion. + * + * @package mod_scorm + * @copyright 2021 Michael Hawkins + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_completion_test extends advanced_testcase { + + /** + * Data provider for get_state(). + * + * @return array[] + */ + public function get_state_provider(): array { + + // Prepare various reusable user scorm track data used to mock various completion states/requirements. + $completionincomplete = (object) [ + 'id' => 1, + 'scoid' => 1, + 'element' => 'cmi.completion_status', + 'value' => 'incomplete', + ]; + + $completionpassed = (object) [ + 'id' => 1, + 'scoid' => 1, + 'element' => 'cmi.completion_status', + 'value' => 'passed', + ]; + + $completioncompleted = (object) [ + 'id' => 1, + 'scoid' => 2, + 'element' => 'cmi.success_status', + 'value' => 'completed', + ]; + + $completionscorefail = (object) [ + 'id' => 1, + 'scoid' => 1, + 'element' => 'cmi.score.raw', + 'value' => '20', + ]; + + $completionscorepass = (object) [ + 'id' => 1, + 'scoid' => 1, + 'element' => 'cmi.score.raw', + 'value' => '100', + ]; + + return [ + 'Undefined completion requirement' => [ + 'somenonexistentrule', COMPLETION_ENABLED, [$completionincomplete], 0, null, coding_exception::class + ], + 'Completion status requirement not available' => [ + 'completionstatusrequired', COMPLETION_DISABLED, [$completionincomplete], 0, null, moodle_exception::class + ], + 'Completion status Passed required, user has no completion status recorded' => [ + 'completionstatusrequired', 2, [], 0, COMPLETION_INCOMPLETE, null + ], + 'Completion status Passed required, user has not passed, can make another attempt' => [ + 'completionstatusrequired', 2, [$completionincomplete], 0, COMPLETION_INCOMPLETE, null + ], + 'Completion status Passed required, user has passed' => [ + 'completionstatusrequired', 2, [$completionpassed], 0, COMPLETION_COMPLETE, null + ], + 'Completion status Completed required, user has not completed, can make another attempt' => [ + 'completionstatusrequired', 4, [$completionincomplete], 2, COMPLETION_INCOMPLETE, null + ], + 'Completion status Completed required, user has completed' => [ + 'completionstatusrequired', 4, [$completioncompleted], 1, COMPLETION_COMPLETE, null + ], + 'Completion status Passed and Completed required, user has only completed, can make another attempt' => [ + 'completionstatusrequired', 6, [$completioncompleted], 0, COMPLETION_INCOMPLETE, null + ], + 'Completion status Passed and Completed required, user has completed and passed' => [ + 'completionstatusrequired', 6, [$completionpassed, $completioncompleted], 0, COMPLETION_COMPLETE, null + ], + 'Completion status Passed required, user has used all attempts and completed, but not passed' => [ + 'completionstatusrequired', 2, [$completioncompleted], 1, COMPLETION_COMPLETE_FAIL, null + ], + 'Completion status Completed required, user has used all attempts, but not completed' => [ + 'completionstatusrequired', 4, [$completionincomplete], 1, COMPLETION_COMPLETE_FAIL, null + ], + 'Completion status Passed and Completed required, user has used all attempts, but not passed' => [ + 'completionstatusrequired', 6, [$completionincomplete, $completioncompleted], 2, COMPLETION_COMPLETE_FAIL, null + ], + 'Completion score required, user has no score' => [ + 'completionscorerequired', 80, [], 0, COMPLETION_INCOMPLETE, null + ], + 'Completion score required, user score does not meet requirement, can make another attempt' => [ + 'completionscorerequired', 80, [$completionscorefail], 0, COMPLETION_INCOMPLETE, null + ], + 'Completion score required, user has used all attempts, but not reached the score' => [ + 'completionscorerequired', 80, [$completionscorefail], 1, COMPLETION_COMPLETE_FAIL, null + ], + 'Completion score required, user score meets requirement' => [ + 'completionscorerequired', 80, [$completionscorepass], 0, COMPLETION_COMPLETE, null + ], + 'Completion of all scos required, user has not completed, can make another attempt' => [ + 'completionstatusallscos', 1, [$completionincomplete, $completioncompleted], 3, COMPLETION_INCOMPLETE, null + ], + 'Completion of all scos required, user has completed' => [ + 'completionstatusallscos', 1, [$completionpassed, $completioncompleted], 2, COMPLETION_COMPLETE, null + ], + 'Completion of all scos required, user has used all attempts, but not completed all scos' => [ + 'completionstatusallscos', 1, [$completionincomplete, $completioncompleted], 2, COMPLETION_COMPLETE_FAIL, null + ], + ]; + } + + /** + * Test for get_state(). + * + * @dataProvider get_state_provider + * @param string $rule The custom completion condition. + * @param int $rulevalue The custom completion rule value. + * @param array $uservalue The relevant record database mock data recorded against the user for the rule. + * @param int $maxattempts The number of attempts the activity allows (0 = unlimited). + * @param int|null $status Expected completion status for the rule. + * @param string|null $exception Expected exception. + */ + public function test_get_state(string $rule, int $rulevalue, array $uservalue, int $maxattempts, ?int $status, + ?string $exception) { + global $DB; + + if (!is_null($exception)) { + $this->expectException($exception); + } + + // Custom completion rule data for cm_info::customdata. + $customdataval = [ + 'customcompletionrules' => [ + $rule => $rulevalue + ] + ]; + + // Build a mock cm_info instance. + $mockcminfo = $this->getMockBuilder(cm_info::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + + // Mock the return of the magic getter method when fetching the cm_info object's + // customdata and instance values. + $mockcminfo->expects($this->any()) + ->method('__get') + ->will($this->returnValueMap([ + ['customdata', $customdataval], + ['instance', 1], + ])); + + // Mock the DB call fetching user's SCORM track data. + $DB = $this->createMock(get_class($DB)); + $DB->expects($this->atMost(1)) + ->method('get_records_sql') + ->willReturn($uservalue); + + // For completed all scos tests, mock the DB call that fetches the sco IDs. + if ($rule === 'completionstatusallscos') { + $returnscos = []; + + foreach ($uservalue as $data) { + $returnscos[$data->scoid] = (object) ['id' => $data->scoid]; + } + + $DB->expects($this->atMost(1)) + ->method('get_records') + ->willReturn($returnscos); + } + + // Anything not complete will check if attempts have been exhausted, mock the DB calls for that check. + if ($status != COMPLETION_COMPLETE) { + $mockscorm = (object) [ + 'id' => 1, + 'version' => SCORM_13, + 'grademethod' => GRADESCOES, + 'maxattempt' => $maxattempts, + ]; + + $DB->expects($this->atMost(1)) + ->method('get_record') + ->willReturn($mockscorm); + + $DB->expects($this->atMost(1)) + ->method('count_records_sql') + ->willReturn(count($uservalue)); + } + + $customcompletion = new custom_completion($mockcminfo, 2); + + $this->assertEquals($status, $customcompletion->get_state($rule)); + } + + /** + * Test for get_defined_custom_rules(). + */ + public function test_get_defined_custom_rules() { + $expectedrules = [ + 'completionstatusrequired', + 'completionscorerequired', + 'completionstatusallscos', + ]; + + $definedrules = custom_completion::get_defined_custom_rules(); + $this->assertCount(3, $definedrules); + + foreach ($definedrules as $definedrule) { + $this->assertContains($definedrule, $expectedrules); + } + } + + /** + * Test for get_defined_custom_rule_descriptions(). + */ + public function test_get_custom_rule_descriptions() { + // Get defined custom rules. + $rules = custom_completion::get_defined_custom_rules(); + + // Build a mock cm_info instance. + $mockcminfo = $this->getMockBuilder(cm_info::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + + // Instantiate a custom_completion object using the mocked cm_info. + $customcompletion = new custom_completion($mockcminfo, 1); + + // Get custom rule descriptions. + $ruledescriptions = $customcompletion->get_custom_rule_descriptions(); + + // Confirm that defined rules and rule descriptions are consistent with each other. + $this->assertEquals(count($rules), count($ruledescriptions)); + foreach ($rules as $rule) { + $this->assertArrayHasKey($rule, $ruledescriptions); + } + } + + /** + * Test for is_defined(). + */ + public function test_is_defined() { + // Build a mock cm_info instance. + $mockcminfo = $this->getMockBuilder(cm_info::class) + ->disableOriginalConstructor() + ->getMock(); + + $customcompletion = new custom_completion($mockcminfo, 1); + + // All rules are defined. + $this->assertTrue($customcompletion->is_defined('completionstatusrequired')); + $this->assertTrue($customcompletion->is_defined('completionscorerequired')); + $this->assertTrue($customcompletion->is_defined('completionstatusallscos')); + + // Undefined rule is not found. + $this->assertFalse($customcompletion->is_defined('somerandomrule')); + } + + /** + * Data provider for test_get_available_custom_rules(). + * + * @return array[] + */ + public function get_available_custom_rules_provider(): array { + return [ + 'Completion status enabled only' => [ + [ + 'completionstatusrequired' => 4, + 'completionscorerequired' => COMPLETION_DISABLED, + 'completionstatusallscos' => COMPLETION_DISABLED, + ], + ['completionstatusrequired'], + ], + 'Completion score enabled only' => [ + [ + 'completionstatusrequired' => COMPLETION_DISABLED, + 'completionscorerequired' => 80, + 'completionstatusallscos' => COMPLETION_DISABLED, + ], + ['completionscorerequired'], + ], + 'Completion status and all scos completed both enabled' => [ + [ + 'completionstatusrequired' => 2, + 'completionscorerequired' => COMPLETION_DISABLED, + 'completionstatusallscos' => COMPLETION_ENABLED, + ], + ['completionstatusrequired', 'completionstatusallscos'], + ], + 'Completion status and score both enabled' => [ + [ + 'completionstatusrequired' => COMPLETION_ENABLED, + 'completionscorerequired' => 80, + 'completionstatusallscos' => COMPLETION_DISABLED, + ], + ['completionstatusrequired', 'completionscorerequired'], + ], + 'All custom completion conditions enabled' => [ + [ + 'completionstatusrequired' => 6, + 'completionscorerequired' => 80, + 'completionstatusallscos' => COMPLETION_ENABLED, + ], + ['completionstatusrequired', 'completionscorerequired', 'completionstatusallscos'], + ], + ]; + } + + /** + * Test for get_available_custom_rules(). + * + * @dataProvider get_available_custom_rules_provider + * @param array $completionrulesvalues + * @param array $expected + */ + public function test_get_available_custom_rules(array $completionrulesvalues, array $expected) { + $customcompletionrules = [ + 'customcompletionrules' => $completionrulesvalues, + ]; + + // Build a mock cm_info instance. + $mockcminfo = $this->getMockBuilder(cm_info::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + + // Mock the return of magic getter for the customdata attribute. + $mockcminfo->expects($this->any()) + ->method('__get') + ->with('customdata') + ->willReturn($customcompletionrules); + + $customcompletion = new custom_completion($mockcminfo, 1); + $this->assertEquals($expected, $customcompletion->get_available_custom_rules()); + } +}