MDL-70935 mod_scorm: Custom completion implementation

This commit is contained in:
Michael Hawkins
2021-04-07 15:12:34 +08:00
parent 7d594cb0d3
commit d938c0f299
3 changed files with 580 additions and 0 deletions
@@ -0,0 +1,205 @@
<?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/>.
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 <[email protected]>
* @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'),
];
}
}
+5
View File
@@ -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.';
+370
View File
@@ -0,0 +1,370 @@
<?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/>.
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 <[email protected]>
* @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());
}
}