From 62417e29a657ce21e92e1999eb25b16698b58586 Mon Sep 17 00:00:00 2001 From: Markus Date: Mon, 2 Sep 2024 17:56:10 +0200 Subject: [PATCH] initial code for support of primitive h5p and refactoring of complex/grading h5p and regular Learning elements to proper grade/completionlib integration --- classes/adler_score.php | 139 +------ classes/adler_score_helpers.php | 6 +- .../score_primitive_learning_element.php | 101 ----- classes/external/trigger_event_cm_viewed.php | 120 ++++++ db/services.php | 8 +- tests/adler_score_helpers_test.php | 10 +- tests/adler_score_test.php | 356 ++++++------------ tests/external/deprecated_mocks.php | 23 -- .../score_primitive_learning_element_test.php | 191 ---------- .../external/trigger_event_cm_viewed_test.php | 111 ++++++ 10 files changed, 370 insertions(+), 695 deletions(-) delete mode 100644 classes/external/score_primitive_learning_element.php create mode 100644 classes/external/trigger_event_cm_viewed.php delete mode 100644 tests/external/score_primitive_learning_element_test.php create mode 100644 tests/external/trigger_event_cm_viewed_test.php diff --git a/classes/adler_score.php b/classes/adler_score.php index 755396d..1bebf7f 100644 --- a/classes/adler_score.php +++ b/classes/adler_score.php @@ -3,10 +3,9 @@ namespace local_adler; -use coding_exception; -use completion_info; +use cm_info; use context_course; -use dml_exception; +use core_completion\cm_completion_details; use local_adler\local\exceptions\user_not_enrolled_exception; use local_logging\logger; use moodle_exception; @@ -24,38 +23,19 @@ class adler_score { protected static string $helpers = helpers::class; - protected static string $completion_info = completion_info::class; - protected static string $adler_score_helpers = adler_score_helpers::class; /** - * @param object $course_module + * @param cm_info $course_module can be retrieved through get_fast_modinfo($course_id)->get_cm($cm_id), see {@link cm_info} * @param int|null $user_id If null, the current user will be used * @throws user_not_enrolled_exception * @throws moodle_exception course_module_format_not_valid, not_an_adler_cm, course_not_adler_course */ - public function __construct(object $course_module, int $user_id = null) { + public function __construct(cm_info $course_module, int $user_id = null) { + global $USER; $this->logger = new logger('local_adler', 'adler_score'); - $this->course_module = $course_module; - - if ($user_id === null) { - global $USER; - $this->user_id = $USER->id; - } else { - $this->user_id = $user_id; - } - - // validate correct course_module format - if (!isset($this->course_module->modname)) { - $this->logger->debug('Moodle hast different course_module formats. ' . - 'The DB-Format and the one returned by get_coursemodule_from_id().' . - ' They are incompatible and only the last one is currently supported by this method.'); - $this->logger->debug('Support for DB format can be implemented if required,' . - ' the required fields are existing there with different names.'); - $this->logger->error('course_module_format_not_valid'); - throw new coding_exception('course_module_format_not_valid', 'local_adler'); - } + $this->user_id = $user_id ?? $USER->id; // validate user is enrolled in course $course_context = context_course::instance($this->course_module->course); @@ -73,108 +53,23 @@ public function __construct(object $course_module, int $user_id = null) { $this->score_item = static::$adler_score_helpers::get_adler_score_record($this->course_module->id); } - /** Calculates the score based on the percentage the user has achieved - * @param float $max_score The maximum score that can be achieved. - * @param float $percentage_achieved As float value between 0 and 1 - */ - private static function calculate_score(float $max_score, float $percentage_achieved): float { - if ($percentage_achieved === 1.) { - return $max_score; - } - return 0.; - } - - /** Calculate percentage achieved between $min and $max - * @param float $min - * @param float $max - * @param float $value - * @return float as float value between 0 and 1 - */ - private static function calculate_percentage_achieved(float $value, float $max, float $min = 0): float { - // This approach is also used by gradebook. - if ($value > $max) { - $value = $max; - } - if ($value < $min) { - $value = $min; - } - return ($value - $min) / ($max - $min); - } - - /** Get course_module id - * @return int - */ - public function get_cmid(): int { - return $this->course_module->id; - } - - - /** Calculates the achieved score for the course module if it is of type h5p. - * There is no type checking, calling this method for a course module that - * is not of type h5p will result in an error. - * @return float - */ - private function get_h5p_score(): float { - global $CFG; - require_once($CFG->libdir . '/gradelib.php'); - - // get h5p result - - - $grading_info = grade_get_grades($this->course_module->course, 'mod', 'h5pactivity', $this->course_module->instance, $this->user_id); - $grading_info = $grading_info->items[0]; - - if ($grading_info->grades[$this->user_id]->grade === null) { - $this->logger->debug('h5p grade not found, probably the user has not submitted the h5p activity yet -> assuming 0%'); - $relative_grade = 0; - } else { - $relative_grade = static::calculate_percentage_achieved( - $grading_info->grades[$this->user_id]->grade, - $grading_info->grademax, - $grading_info->grademin - ); - } - return self::calculate_score($this->score_item->score_max, $relative_grade); - } - - /** Calculates the achieved score for a primitive course module. There is no type checking, calling this method - * for a course module that is not a primitive cm will result in a wrong score or an error. + /** + * Calculates the achieved score for the course module based on its completion state. + * * @return float - * @throws moodle_exception + * @throws moodle_exception If completion is not enabled for the course module. */ - private function get_primitive_score(): float { - // get completion object - $course = static::$helpers::get_course_from_course_id($this->course_module->course); - $completion = new static::$completion_info($course); + public function get_score_by_completion_state(): float { + $cm_completion_details = cm_completion_details::get_instance( + get_fast_modinfo($this->course_module->course)->get_cm($this->course_module->id), + $this->user_id + ); // check if completion is enabled for this course_module - if (!$completion->is_enabled($this->course_module)) { + if (!$cm_completion_details->has_completion()) { throw new moodle_exception('completion_not_enabled', 'local_adler'); } - // get completion status - $completion_status = (float)$completion->get_data($this->course_module, false, $this->user_id)->completionstate; - - // completionstate has multiple options, not just COMPLETE and INCOMPLETE - $is_completed_successfully = $completion_status == COMPLETION_COMPLETE || $completion_status == COMPLETION_COMPLETE_PASS; - - return self::calculate_score($this->score_item->score_max, $is_completed_successfully); - } - - /** Get the score for the course module. - * Gets the completion status and for h5p activities the achieved grade and calculates the adler score with the values from - * local_adler_course_modules. - * @throws dml_exception|moodle_exception - */ - public function get_score(): float { - // if course_module is a h5p activity, get achieved grade - if ($this->course_module->modname == 'h5pactivity') { - return $this->get_h5p_score(); - } - - // if course_module is not a h5p activity, get completion status - $this->logger->debug('course_module is either a primitive or an unsupported complex activity'); - - return $this->get_primitive_score(); + return $cm_completion_details->is_overall_complete() ? $this->score_item->score_max : 0; } } diff --git a/classes/adler_score_helpers.php b/classes/adler_score_helpers.php index 045134e..697cfc1 100644 --- a/classes/adler_score_helpers.php +++ b/classes/adler_score_helpers.php @@ -24,9 +24,11 @@ public static function get_adler_score_objects(array $module_ids, int $user_id = $adler_scores = array(); foreach ($module_ids as $module_id) { $course_module = get_coursemodule_from_id(null, $module_id, 0, false, MUST_EXIST); + $course_module = get_fast_modinfo($course_module->course)->get_cm($course_module->id); try { $adler_scores[$module_id] = new static::$adler_score_class($course_module, $user_id); } catch (moodle_exception $e) { +// todo: either moodle_exception is really never thrown, then delete code, otherwise fix linting if ($e->errorcode === 'not_an_adler_cm') { $logger->info('Is adler course, but adler scoring is not enabled for cm with id ' . $module_id); $adler_scores[$module_id] = false; @@ -56,7 +58,7 @@ public static function get_achieved_scores(?array $module_ids, int $user_id = nu if ($adler_score === false) { $achieved_scores[$cmid] = false; } else { - $achieved_scores[$cmid] = $adler_score->get_score(); + $achieved_scores[$cmid] = $adler_score->get_score_by_completion_state(); } } return $achieved_scores; @@ -69,6 +71,7 @@ public static function get_achieved_scores(?array $module_ids, int $user_id = nu * @throws moodle_exception */ public static function get_adler_score_record(int $cmid): stdClass { +// todo: repo pattern $logger = new logger('local_adler', 'adler_score_helpers'); global $DB; $record = $DB->get_record('local_adler_course_modules', array('cmid' => $cmid)); @@ -84,6 +87,7 @@ public static function get_adler_score_record(int $cmid): stdClass { * @throws dml_exception */ public static function delete_adler_score_record(int $cmid): void { +// todo: repo pattern global $DB; $DB->delete_records('local_adler_course_modules', array('cmid' => $cmid)); } diff --git a/classes/external/score_primitive_learning_element.php b/classes/external/score_primitive_learning_element.php deleted file mode 100644 index c608d6c..0000000 --- a/classes/external/score_primitive_learning_element.php +++ /dev/null @@ -1,101 +0,0 @@ -dirroot . '/lib/externallib.php'); - -use completion_info; -use context_course; -use dml_exception; -use dml_transaction_exception; -use external_api; -use external_function_parameters; -use external_value; -use invalid_parameter_exception; -use local_adler\adler_score; -use local_adler\helpers; -use local_logging\logger; -use moodle_exception; -use restricted_context_exception; - -class score_primitive_learning_element extends external_api { - public static function execute_parameters(): external_function_parameters { - return new external_function_parameters( - array( - 'module_id' => new external_value(PARAM_INT, 'moodle module id', VALUE_REQUIRED), - 'is_completed' => new external_value(PARAM_BOOL, '1: completed, 0: not completed', VALUE_REQUIRED), - ) - ); - } - - public static function execute_returns(): external_function_parameters { - return lib::get_adler_score_response_multiple_structure(); - } - - /** creates adler_score objects, simplifies testing - * @param $course_module object course module object with field modname - * @return adler_score for currently logged-in user - * @throws moodle_exception - */ - protected static function create_adler_score_instance($course_module): adler_score { - return new adler_score($course_module); - } - - /** - * @throws restricted_context_exception - * @throws dml_transaction_exception - * @throws moodle_exception - * @throws invalid_parameter_exception - */ - public static function execute($module_id, $is_completed): array { - $logger = new logger('local_adler', 'score_primitive_learning_element'); - global $CFG; - require_once($CFG->libdir . '/completionlib.php'); - - // Parameter validation - $params = self::validate_parameters(self::execute_parameters(), array( - 'module_id' => $module_id, - 'is_completed' => $is_completed - )); - - // get moodle course object $course - try { - $course_module = get_coursemodule_from_id(null, $params['module_id'], 0, false, MUST_EXIST); - } catch (dml_exception $e) { - // PHPStorm says this exception is never thrown, but this is wrong, - // see test test_score_primitive_learning_element_course_module_not_exist - throw new invalid_parameter_exception('failed_to_get_course_module'); - } - $course_id = $course_module->course; - $course = helpers::get_course_from_course_id($course_id); - - // security stuff https://docs.moodle.org/dev/Access_API#Context_fetching - $context = context_course::instance($course_id); - self::validate_context($context); - - // check if course_module is a primitive learning element. If it's supporting gradelib it might cause unexpected behaviour if manually setting completion state - if (helpers::is_primitive_learning_element($course_module)) { - $completion = new completion_info($course); - - // check if completion is enabled for this course_module - if (!$completion->is_enabled($course_module)) { - throw new moodle_exception('completion_not_enabled', 'local_adler'); - } - - // update completion status - $new_completion_state = COMPLETION_INCOMPLETE; - if ($params['is_completed']) { - $new_completion_state = COMPLETION_COMPLETE; - } - $completion->update_state($course_module, $new_completion_state); - - // return adler score - $adler_score = static::create_adler_score_instance($course_module); - return ['data' => lib::convert_adler_score_array_format_to_response_structure( - array($course_module->id => $adler_score->get_score()))]; - } else { - $logger->warning("Course module is not a known primitive learning element."); - throw new moodle_exception("course_module_is_not_a_primitive_learning_element", 'local_adler'); - } - } -} diff --git a/classes/external/trigger_event_cm_viewed.php b/classes/external/trigger_event_cm_viewed.php new file mode 100644 index 0000000..f556b2e --- /dev/null +++ b/classes/external/trigger_event_cm_viewed.php @@ -0,0 +1,120 @@ +dirroot . '/lib/externallib.php'); + +use coding_exception; +use completion_info; +use context_course; +use context_module; +use dml_exception; +use dml_transaction_exception; +use external_api; +use external_function_parameters; +use external_value; +use invalid_parameter_exception; +use local_adler\adler_score; +use local_adler\adler_score_helpers; +use local_adler\helpers; +use local_adler\local\exceptions\not_an_adler_cm_exception; +use local_adler\local\exceptions\not_an_adler_course_exception; +use local_logging\logger; +use moodle_exception; +use restricted_context_exception; + +class trigger_event_cm_viewed extends external_api { + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters( + array( + 'module_id' => new external_value(PARAM_INT, 'moodle module id', VALUE_REQUIRED), + ) + ); + } + + public static function execute_returns(): external_function_parameters { + return lib::get_adler_score_response_multiple_structure(); + } + + /** + * @throws restricted_context_exception + * @throws dml_transaction_exception + * @throws moodle_exception + * @throws invalid_parameter_exception + */ + public static function execute($module_id): array { + // TODO: a lot of unused code -> refactor + $logger = new logger('local_adler', 'trigger_event_cm_viewed'); + + // Parameter validation + $params = self::validate_parameters(self::execute_parameters(), array( + 'module_id' => $module_id, + )); + + // get moodle course object $course + try { + $course_module = get_coursemodule_from_id(null, $params['module_id'], 0, false, MUST_EXIST); + } catch (dml_exception $e) { + // PHPStorm says this exception is never thrown, but this is wrong, + // see test test_score_primitive_learning_element_course_module_not_exist + throw new invalid_parameter_exception('failed_to_get_course_module'); + } + $course_module_cm_info = get_fast_modinfo($course_module->course)->get_cm($course_module->id); + $course_id = $course_module->course; + $course = helpers::get_course_from_course_id($course_id); + + // validate course is adler course + if (!helpers::course_is_adler_course($course->id)) { + throw new not_an_adler_course_exception(); + } + // validate course module is adler course module + try { + adler_score_helpers::get_adler_score_record($course_module->id); + // todo: improve this -> separate function to test if cm is adler cm + } catch (not_an_adler_cm_exception $e) { + throw $e; + } + + // security stuff https://docs.moodle.org/dev/Access_API#Context_fetching + $context = context_course::instance($course_id); + self::validate_context($context); + + + + // trigger event + self::trigger_module_specific_view_event($course_module, $course); + + + // return adler score + $adler_score = new adler_score($course_module_cm_info); + return ['data' => lib::convert_adler_score_array_format_to_response_structure( + array($course_module->id => $adler_score->get_score_by_completion_state()))]; + } + + /** + * @throws coding_exception + */ + private static function trigger_module_specific_view_event($course_module, $course) { + $module_context = context_module::instance($course_module->id); + + // Determine the specific event class for the module + $event_class = "\\mod_{$course_module->modname}\\event\\course_module_viewed"; + if (!class_exists($event_class)) { + throw new coding_exception("Event class $event_class does not exist"); + } + + // Trigger the event + $event = $event_class::create([ + 'context' => $module_context, + 'objectid' => $course_module->id, + ]); + $event->trigger(); + + + // completion + $completion = new completion_info($course); + $completion->set_module_viewed($course_module); + + // todo: maybe prefer _view() function of each module if it exists + } +} diff --git a/db/services.php b/db/services.php index 857d0dc..2ea15a3 100644 --- a/db/services.php +++ b/db/services.php @@ -23,15 +23,15 @@ ); $functions = array( - 'local_adler_score_primitive_learning_element' => array( //web service function name - 'classname' => 'local_adler\external\score_primitive_learning_element', //class containing the external function OR namespaced class in classes/external/XXXX.php - 'description' => 'Submit result for primitive learning elements (completed/not completed)', //human readable description of the web service function + 'local_adler_trigger_event_cm_viewed' => [ //web service function name + 'classname' => 'local_adler\external\trigger_event_cm_viewed', //class containing the external function OR namespaced class in classes/external/XXXX.php + 'description' => 'Marks the element as viewed by triggering the "viewed" event for the cm.', //human readable description of the web service function 'type' => 'write', //database rights of the web service function (read, write) 'ajax' => false, // is the service available to 'internal' ajax calls. 'services' => array('adler_services'), // Optional, only available for Moodle 3.1 onwards. List of built-in services (by shortname) where the function will be included. Services created manually via the Moodle interface are not supported. 'capabilities' => '', // comma separated list of capabilities used by the function. 'loginrequired' => true - ), + ], 'local_adler_score_h5p_learning_element' => array( 'classname' => 'local_adler\external\score_h5p_learning_element', 'description' => 'Submit result for h5p. This is just a proxy function and forwards its payload to {"wsfunction", "core_xapi_statement_post"}, {"component", "mod_h5pactivity"}, {"requestjson", "[" + statement + "]"}', //human readable description of the web service function diff --git a/tests/adler_score_helpers_test.php b/tests/adler_score_helpers_test.php index 1425c36..570291b 100644 --- a/tests/adler_score_helpers_test.php +++ b/tests/adler_score_helpers_test.php @@ -105,18 +105,18 @@ public function provide_test_get_achieved_scores_data(): array { * # ANF-ID: [MVP9, MVP8, MVP7] */ public function test_get_achieved_scores($data) { - // create 3 adler_score objects and mock get_score + // create 3 adler_score objects and mock get_score_by_completion_state for ($i = 0; $i < 3; $i++) { $adler_score_objects[] = $this->getMockBuilder(adler_score_helpers_adler_score_mock::class) ->disableOriginalConstructor() ->getMock(); - $adler_score_objects[$i]->method('get_score')->willReturn((float)$i * 2); + $adler_score_objects[$i]->method('get_score_by_completion_state')->willReturn((float)$i * 2); } $adler_score_objects[] = false; // setup exception if ($data['exception'] !== null) { - $adler_score_objects[$data['exception_at_index']]->method('get_score')->willThrowException( + $adler_score_objects[$data['exception_at_index']]->method('get_score_by_completion_state')->willThrowException( new $data['exception']($data['exception_msg']) ); } @@ -145,12 +145,12 @@ public function test_get_achieved_scores_with_module_ids() { // setup $module_ids = [1, 2, 3]; $user_id = 1; - // create 3 adler_score objects and mock get_score + // create 3 adler_score objects and mock get_score_by_completion_state for ($i = 0; $i < 3; $i++) { $adler_score_objects[] = $this->getMockBuilder(adler_score_helpers_adler_score_mock::class) ->disableOriginalConstructor() ->getMock(); - $adler_score_objects[$i]->method('get_score')->willReturn((float)$i * 2); + $adler_score_objects[$i]->method('get_score_by_completion_state')->willReturn((float)$i * 2); } $expected_result = [0, 2, 4]; diff --git a/tests/adler_score_test.php b/tests/adler_score_test.php index caccd31..20fe144 100644 --- a/tests/adler_score_test.php +++ b/tests/adler_score_test.php @@ -3,18 +3,15 @@ namespace local_adler; -use coding_exception; use completion_info; +use grade_item; use local_adler\lib\adler_testcase; use local_adler\lib\static_mock_utilities_trait; use local_adler\local\exceptions\user_not_enrolled_exception; -use local_logging\logger; -use Mockery; -use mod_h5pactivity\local\grader; use moodle_exception; -use ReflectionClass; use stdClass; use Throwable; +use TypeError; global $CFG; require_once($CFG->dirroot . '/local/adler/tests/lib/adler_testcase.php'); @@ -74,17 +71,14 @@ public function setUp(): void { $this->course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]); // create module - $this->module = $this->getDataGenerator()->create_module('url', ['course' => $this->course->id, 'completion' => 1]); + $this->module = $this->getDataGenerator()->create_module('url', [ + 'course' => $this->course->id, + 'completion' => COMPLETION_TRACKING_AUTOMATIC, + 'completeionview' => 1, + 'completionpassgrade' => 0 + ]); } - public function tearDown(): void { - parent::tearDown(); - - $reflection = new ReflectionClass(adler_score::class); - $property = $reflection->getProperty('completion_info'); - $property->setAccessible(true); - $property->setValue(completion_info::class); - } public function provide_test_construct_data() { // double array for each case because phpunit otherwise splits the object into individual params @@ -98,7 +92,6 @@ public function provide_test_construct_data() { 'is_adler_cm' => true, 'expect_exception' => false, 'expect_exception_message' => null, - ]], 'with user id param' => [[ 'enrolled' => true, @@ -109,7 +102,6 @@ public function provide_test_construct_data() { 'is_adler_cm' => true, 'expect_exception' => false, 'expect_exception_message' => null, - ]], 'not enrolled' => [[ 'enrolled' => false, @@ -128,8 +120,8 @@ public function provide_test_construct_data() { 'course_module_param' => 'incorrect', 'is_adler_course' => true, 'is_adler_cm' => true, - 'expect_exception' => coding_exception::class, - 'expect_exception_message' => 'course_module_format_not_valid', + 'expect_exception' => TypeError::class, + 'expect_exception_message' => 'must be of type cm_info', ]], 'not adler course' => [[ 'enrolled' => true, @@ -203,274 +195,142 @@ public function test_construct($test) { // No exception thrown and no exception expected -> check result // test score $this->assertEquals(17, $result->test_get_score_item()->score); - $this->assertEquals($module_format_correct->id, $result->get_cmid()); } public function provide_test_get_primitive_score_data() { return [ - 'complete' => [[ - 'completion_enabled' => true, + 'complete' => [ + 'completion_enabled_cm' => true, + 'completion_enabled_course' => true, 'completion_state' => COMPLETION_COMPLETE, 'expect_exception' => false, 'expect_exception_message' => null, - 'expect_score' => 1, - ]], - 'incomplete' => [[ - 'completion_enabled' => true, + 'expect_score' => 100, + ], + 'incomplete' => [ + 'completion_enabled_cm' => true, + 'completion_enabled_course' => true, 'completion_state' => COMPLETION_INCOMPLETE, 'expect_exception' => false, 'expect_exception_message' => null, 'expect_score' => 0, - ]], - 'completion_disabled' => [[ - 'completion_enabled' => false, + ], + 'completion_disabled' => [ + 'completion_enabled_cm' => false, + 'completion_enabled_course' => false, 'completion_state' => COMPLETION_INCOMPLETE, 'expect_exception' => moodle_exception::class, 'expect_exception_message' => "completion_not_enabled", 'expect_score' => 0, - ]], + ], + 'completion_disabled_cm' => [ + 'completion_enabled_cm' => false, + 'completion_enabled_course' => true, + 'completion_state' => COMPLETION_INCOMPLETE, + 'expect_exception' => moodle_exception::class, + 'expect_exception_message' => "completion_not_enabled", + 'expect_score' => 0, + ], ]; } - /** - * @dataProvider provide_test_get_primitive_score_data - * - * # ANF-ID: [MVP10] - */ - public function test_get_primitive_score($data) { + private function set_up_course_with_primitive_element(bool $enable_completion_course, bool $enable_completion_module) { + // create user, course and enrol user + $this->user = $this->getDataGenerator()->create_user(); + $this->course = $this->getDataGenerator()->create_course(['enablecompletion' => $enable_completion_course ? '1' : '0']); + $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id); + $this->setUser($this->user); + // create primitive activity - $generator = $this->getDataGenerator()->get_plugin_generator('mod_url'); - $cm = $generator->create_instance(array( + $cm_data = [ 'course' => $this->course->id, - )); - $cm_other_format = get_fast_modinfo($this->course->id)->get_cm($cm->cmid); - - // Create score (adler) item. - $score_item = $this->getDataGenerator() - ->get_plugin_generator('local_adler') - ->create_adler_course_module($cm_other_format->id, [], false); - - - // create adler_score object and set private properties - $reflection = new ReflectionClass(adler_score::class); - // create adler_score without constructor - $adler_score = $reflection->newInstanceWithoutConstructor(); - // set private properties of adler_score - $property = $reflection->getProperty('score_item'); - $property->setAccessible(true); - $property->setValue($adler_score, $score_item); - $property = $reflection->getProperty('course_module'); - $property->setAccessible(true); - $property->setValue($adler_score, $cm_other_format); - $property = $reflection->getProperty('user_id'); - $property->setAccessible(true); - $property->setValue($adler_score, $this->user->id); - - // set completion_info mock - $property = $reflection->getProperty('completion_info'); - $property->setAccessible(true); - $property->setValue($adler_score, completion_info_mock::class); - - // set parameters for completion_info mock - completion_info_mock::reset_data(); - completion_info_mock::set_returns('is_enabled', [$data['completion_enabled']]); - completion_info_mock::set_returns('get_data', [(object)['completionstate' => $data['completion_state']]]); - - // mock logger as it does not exist because constructor is not executed - $logger_mock = Mockery::mock(Logger::class); - // ignore all method calls on mock - $logger_mock->shouldIgnoreMissing(); - // set logger mock to $logger variable in class under test - $property = $reflection->getProperty('logger'); - $property->setAccessible(true); - $property->setValue($adler_score, $logger_mock); - - // call method - try { - $result = $adler_score->get_score(); - } catch (Throwable $e) { - $this->assertEquals($data['expect_exception'], get_class($e)); - if ($data['expect_exception_message'] !== null) { - $this->assertStringContainsString($data['expect_exception_message'], $e->getMessage()); - } - return; + 'completion' => $enable_completion_module ? COMPLETION_TRACKING_AUTOMATIC : COMPLETION_TRACKING_NONE]; + if ($enable_completion_module) { + $cm_data += [ + 'completionview' => 1, + 'completionpassgrade' => 0 + ]; } + $url_module = $this->getDataGenerator()->get_plugin_generator('mod_url')->create_instance($cm_data); + $this->url_module_cm_info = get_fast_modinfo($url_module->course)->get_cm($url_module->cmid); - $this->assertEquals($data['expect_score'] == 1 ? $score_item->score_max : 0, $result); - + // make course and module adler course/module + $adler_generator = $this->getDataGenerator()->get_plugin_generator('local_adler'); + $adler_generator->create_adler_course_object($this->course->id); + $adler_generator->create_adler_course_module($url_module->cmid); } - /** h5p attempt generator is not calculating the scaled attribute. - * When accessing h5pactivity_attempts it's not using the rawscore field, - * but instead calculates the scaled value (maxscore * scaled), making this field required for tests. - * This method works around this issue by calculating the redundant "scaled" field for all existing attempts. + /** * - * Note that this method does not set/update gradebook entries. + * @dataProvider provide_test_get_primitive_score_data + * # ANF-ID: [MVP7, MVP9, MVP10, MVP8] */ - private function fix_scaled_attribute_of_h5pactivity_attempts() { - global $DB; + public function test_get_primitive_score(bool $completion_enabled_cm, bool $completion_enabled_course, int $completion_state, string|false $expect_exception, ?string $expect_exception_message, int $expect_score) { + $this->set_up_course_with_primitive_element($completion_enabled_course, $completion_enabled_cm); - $attempts = $DB->get_records('h5pactivity_attempts'); - foreach ($attempts as $attempt) { - $attempt->scaled = $attempt->rawscore / $attempt->maxscore; - $DB->update_record('h5pactivity_attempts', $attempt); + if ($completion_state === COMPLETION_COMPLETE) { + $completion = new completion_info($this->course); + $completion->set_module_viewed($this->url_module_cm_info); } - } - /** - * @medium - * - * # ANF-ID: [MVP7] - */ - public function test_get_score_for_h5p_learning_element() { - global $CFG; - require_once($CFG->libdir . '/gradelib.php'); + $adler_score = new adler_score($this->url_module_cm_info); + try { + $result = $adler_score->get_score_by_completion_state(); + } catch (Throwable $e) { + $this->assertEquals($expect_exception, get_class($e)); + if ($expect_exception_message !== null) { + $this->assertStringContainsString($expect_exception_message, $e->getMessage()); + } + return; + } + $this->assertEquals($expect_score, $result); + } - // set current user (required by h5p generator) + private function set_up_course_with_h5p_grade_element() { + // create user, course and enrol user + $this->user = $this->getDataGenerator()->create_user(); + $this->course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]); + $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id); $this->setUser($this->user); - - // create h5p activity - $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity'); - $cm = $generator->create_instance(array( + // create h5p with completionpassgrade + $this->h5p_module = $this->getDataGenerator()->create_module('h5pactivity', [ 'course' => $this->course->id, - )); - $cm_other_format = get_fast_modinfo($this->course->id)->get_cm($cm->cmid); - - // Create score (adler) item. - $score_item_h5p = $this->getDataGenerator() - ->get_plugin_generator('local_adler') - ->create_adler_course_module($cm_other_format->id, [], false); - - - // create adler_score object and set private properties - $reflection = new ReflectionClass(adler_score::class); - // create adler_score without constructor - $adler_score = $reflection->newInstanceWithoutConstructor(); - // set private properties of adler_score - $property = $reflection->getProperty('score_item'); - $property->setAccessible(true); - $property->setValue($adler_score, $score_item_h5p); - $property = $reflection->getProperty('course_module'); - $property->setAccessible(true); - $property->setValue($adler_score, $cm_other_format); - $property = $reflection->getProperty('user_id'); - $property->setAccessible(true); - $property->setValue($adler_score, $this->user->id); - - // mock logger as it does not exist because constructor is not executed - $logger_mock = Mockery::mock(Logger::class); - // ignore all method calls on mock - $logger_mock->shouldIgnoreMissing(); - // set logger mock to $logger variable in class under test - $property = $reflection->getProperty('logger'); - $property->setAccessible(true); - $property->setValue($adler_score, $logger_mock); - - // test no attempt - // call method - $result = $adler_score->get_score(); - $this->assertEquals(0, $result); - - - // Test with attempts. - - // create grader - $grader = new grader($cm); - - // array with test data for attempts with different maxscores and rawscores - $test_data = [ - ['maxscore' => 100, 'rawscore' => 0, 'expected_score' => 0], - ['maxscore' => 100, 'rawscore' => 100, 'expected_score' => 100], - ['maxscore' => 100, 'rawscore' => 50, 'expected_score' => 0], - ['maxscore' => 50, 'rawscore' => 0, 'expected_score' => 0], - ['maxscore' => 50, 'rawscore' => 50, 'expected_score' => 100], - ['maxscore' => 50, 'rawscore' => 25, 'expected_score' => 0], - ['maxscore' => 200, 'rawscore' => 0, 'expected_score' => 0], - ['maxscore' => 200, 'rawscore' => 200, 'expected_score' => 100], - ['maxscore' => 200, 'rawscore' => 100, 'expected_score' => 0], - ]; - - // test attempts with different maxscores and rawscores - foreach ($test_data as $data) { - // Create h5p attempt - $params = [ - 'h5pactivityid' => $cm->id, - 'userid' => $this->user->id, - 'rawscore' => $data['rawscore'], - 'maxscore' => $data['maxscore'] - ]; - $generator->create_attempt($params); - $this->fix_scaled_attribute_of_h5pactivity_attempts(); - - // Create grade entry (grade_grades) - $grader->update_grades(); - - // check result - $this->assertEquals(round($data['expected_score'], 3), round($adler_score->get_score(), 3)); - } - - - // test invalid rawscore - $params = [[ - 'h5pactivityid' => $cm->id, - 'userid' => $this->user->id, - 'rawscore' => -1, - 'maxscore' => 100 - ], [ - 'h5pactivityid' => $cm->id, - 'userid' => $this->user->id, - 'rawscore' => 101, - 'maxscore' => 100 - ]]; - // use indexed loop - for ($i = 0; $i < count($params); $i++) { - $generator->create_attempt($params[$i]); - $this->fix_scaled_attribute_of_h5pactivity_attempts(); - - // Create grade entry (grade_grades) - $grader->update_grades(); - - // check result - $this->assertEquals($i == 0 ? 0 : $params[$i]['maxscore'], $adler_score->get_score()); - } + 'completion' => COMPLETION_TRACKING_AUTOMATIC, + 'completionview' => 0, + 'completionpassgrade' => 1, + 'completiongradeitemnumber' => 0 + ]); + + // make course and module adler course/module + $adler_generator = $this->getDataGenerator()->get_plugin_generator('local_adler'); + $adler_generator->create_adler_course_object($this->course->id); + $this->h5p_adler_cm = $adler_generator->create_adler_course_module($this->h5p_module->cmid); } /** - * # ANF-ID: [MVP10, MVP9, MVP8, MVP7] + * # ANF-ID: [MVP7, MVP8, MVP9] */ - public function test_calculate_percentage_achieved() { - // test setup - // create adler_score object without constructor call - $adler_score = $this->getMockBuilder(adler_score::class) - ->disableOriginalConstructor() - ->getMock(); - - // make calculate_percentage_achieved public - $reflection = new ReflectionClass(adler_score::class); - $method = $reflection->getMethod('calculate_percentage_achieved'); - $method->setAccessible(true); - - // enroll user - $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, 'student'); - - // test data - $test_data = [ - ['min' => 0, 'max' => 100, 'value' => 0, 'expected' => 0], - ['min' => 0, 'max' => 100, 'value' => 50, 'expected' => .5], - ['min' => 0, 'max' => 100, 'value' => 100, 'expected' => 1], - ['min' => 10, 'max' => 20, 'value' => 10, 'expected' => 0], - ['min' => 10, 'max' => 20, 'value' => 15, 'expected' => .5], - ['min' => 10, 'max' => 20, 'value' => 20, 'expected' => 1], - ['min' => 0, 'max' => 100, 'value' => -1, 'expected' => 0], - ['min' => 0, 'max' => 100, 'value' => 101, 'expected' => 1], - ]; + public function test_get_score_for_h5p_grade_learning_element() { + $this->set_up_course_with_h5p_grade_element(); - // test - foreach ($test_data as $data) { - $result = $method->invokeArgs($adler_score, [$data['value'], $data['max'], $data['min']]); - $this->assertEquals($data['expected'], $result); - } + global $CFG; + require_once($CFG->libdir . '/gradelib.php'); + $grade_item = grade_item::fetch([ + 'itemname' => $this->h5p_module->name, + 'gradetype' => GRADE_TYPE_VALUE, + 'courseid' => $this->h5p_module->course + ]); + $grade_data_class = new stdClass(); + $grade_data_class->userid = $this->user->id; + $grade_data_class->rawgrade = $this->h5p_module->grade; + require_once($CFG->dirroot . '/mod/h5pactivity/lib.php'); + h5pactivity_grade_item_update($this->h5p_module, $grade_data_class); + + $h5p_module_as_course_modinfo = get_fast_modinfo($this->course->id)->get_cm($this->h5p_module->cmid); + $adler_score = new adler_score($h5p_module_as_course_modinfo); + + $this->assertEquals($this->h5p_adler_cm->score_max, $adler_score->get_score_by_completion_state()); } } diff --git a/tests/external/deprecated_mocks.php b/tests/external/deprecated_mocks.php index b1f1c2d..afcbc70 100644 --- a/tests/external/deprecated_mocks.php +++ b/tests/external/deprecated_mocks.php @@ -20,26 +20,3 @@ class score_get_element_scores_mock extends score_get_element_scores { protected static string $adler_score_helpers = adler_score_helpers_mock::class; protected static string $context_module = context_module_mock::class; } - -/** - * @deprecated use Mockery instead - */ -class mock_score_primitive_learning_element extends score_primitive_learning_element { - private static $index = 0; - private static $data = array(); - - public static function set_data(array $data) { - self::$data = $data; - self::$index = 0; - } - - protected static function create_adler_score_instance($course_module): adler_score { - self::$index += 1; - return self::$data[self::$index - 1]; - } - - public static function call_create_adler_score_instance($course_module): adler_score { - // call protected function - return parent::create_adler_score_instance($course_module); - } -} diff --git a/tests/external/score_primitive_learning_element_test.php b/tests/external/score_primitive_learning_element_test.php deleted file mode 100644 index 33262da..0000000 --- a/tests/external/score_primitive_learning_element_test.php +++ /dev/null @@ -1,191 +0,0 @@ -dirroot . '/webservice/tests/helpers.php'); -require_once($CFG->dirroot . '/local/adler/tests/lib/adler_testcase.php'); - - -/** - * @runTestsInSeparateProcesses - * @preserveGlobalState disabled - */ -class score_primitive_learning_element_test extends adler_externallib_testcase { - // Define the properties explicitly - public $course; - public $course_module; - public $user; - public $mock_adler_score; - - public function setUp(): void { - parent::setUp(); - - require_once(__DIR__ . '/deprecated_mocks.php'); - - // init test data - $this->course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]); - - $this->course_module = $this->getDataGenerator()->create_module('url', array('course' => $this->course->id, 'completion' => 1)); - $this->course_module = get_coursemodule_from_id(null, $this->course_module->cmid, 0, false, MUST_EXIST); - - $this->user = $this->getDataGenerator()->create_user(); - $this->setUser($this->user); - $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, 'student'); - - // mock adler_score class - $this->mock_adler_score = $this->getMockBuilder(adler_score::class) - ->disableOriginalConstructor() - ->getMock(); - $this->mock_adler_score->method('get_score') - ->willReturn(42.0); - } - - - /** - * # ANF-ID: [MVP10] - */ - public function test_score_primitive_learning_element() { - // set data for mocked create_adler_score_instance method - mock_score_primitive_learning_element::set_data(array($this->mock_adler_score, $this->mock_adler_score)); - - // Call CUT - $result = mock_score_primitive_learning_element::execute($this->course_module->id, false); - - // Check result - $this->assertEquals(42.0, $result['data'][0]['score']); - $completion = new completion_info($this->course); - $this->assertFalse((bool)$completion->get_data($this->course_module)->completionstate); - external_api::validate_parameters( // if this fails an exception will be thrown and the test fails - mock_score_primitive_learning_element::execute_returns(), - $result - ); - - // Test again with completed = true - mock_score_primitive_learning_element::execute($this->course_module->id, true); - $completion = new completion_info($this->course); - $this->assertTrue((bool)$completion->get_data($this->course_module)->completionstate); - external_api::validate_parameters( // if this fails an exception will be thrown and the test fails - mock_score_primitive_learning_element::execute_returns(), - $result - ); - } - - /** - * # ANF-ID: [MVP10] - */ - public function test_score_primitive_learning_element_wrong_datatypes() { - $exception_thrown = false; - try { - mock_score_primitive_learning_element::execute($this->course_module->id, "True"); - } catch (invalid_parameter_exception $e) { - $exception_thrown = true; - } finally { - $this->assertTrue($exception_thrown, "Invalid parameter exception not thrown"); - } - - $exception_thrown = false; - try { - mock_score_primitive_learning_element::execute($this->course_module, true); - } catch (invalid_parameter_exception $e) { - $exception_thrown = true; - } finally { - $this->assertTrue($exception_thrown, "Invalid parameter exception not thrown"); - } - } - - /** - * # ANF-ID: [MVP10] - */ - public function test_score_primitive_learning_element_h5p() { - // create h5p activity - $this->course_module = $this->getDataGenerator()->create_module('h5pactivity', array('course' => $this->course->id, 'completion' => 1)); - $this->course_module = get_coursemodule_from_id(null, $this->course_module->cmid, 0, false, MUST_EXIST); - - // set data for mocked create_adler_score_instance method - mock_score_primitive_learning_element::set_data(array($this->mock_adler_score)); - - $this->expectException('moodle_exception'); - $this->expectExceptionMessage("course_module_is_not_a_primitive_learning_element"); - - // Call CUT - mock_score_primitive_learning_element::execute($this->course_module->id, false); - } - - /** - * # ANF-ID: [MVP10] - */ - public function test_score_primitive_learning_element_completion_disabled() { - // create module with disabled completion - $course_module = $this->getDataGenerator()->create_module( - 'url', - array('course' => $this->course->id, 'completion' => 0)); - - // expect exception - $this->expectException('moodle_exception'); - $this->expectExceptionMessage("completion_not_enabled"); - - // call CUT - mock_score_primitive_learning_element::execute($course_module->cmid, true); - } - - /** - * # ANF-ID: [MVP10] - */ - public function test_score_primitive_learning_element_user_not_enrolled() { - // set data for mocked create_adler_score_instance method - mock_score_primitive_learning_element::set_data(array($this->mock_adler_score)); - - // create and enroll user - $user = $this->getDataGenerator()->create_user(); - $this->setUser($user); - - $this->expectException('require_login_exception'); - $this->expectExceptionMessageMatches("/Not enrolled/"); - - // Call CUT - mock_score_primitive_learning_element::execute($this->course_module->id, false); - } - - /** - * # ANF-ID: [MVP10] - */ - public function test_score_primitive_learning_element_course_module_not_exist() { - // set data for mocked create_adler_score_instance method - mock_score_primitive_learning_element::set_data(array($this->mock_adler_score)); - - $this->expectException('invalid_parameter_exception'); - $this->expectExceptionMessage("failed_to_get_course_module"); - - // Call CUT - mock_score_primitive_learning_element::execute(987654321, false); - } - - /** - * # ANF-ID: [MVP10] - */ - public function test_execute_returns() { - // this function just returns what get_adler_score_response_multiple_structure returns - require_once(__DIR__ . '/lib_test.php'); - (new lib_test())->test_get_adler_score_response_multiple_structure(score_primitive_learning_element::class); - } - - /** - * # ANF-ID: [MVP10] - */ - public function test_create_adler_score_instance() { - $mock = new mock_score_primitive_learning_element(); - $this->expectException('moodle_exception'); - $this->expectExceptionMessage("local_adler/not_an_adler_course"); - $mock->call_create_adler_score_instance($this->course_module); - } -} diff --git a/tests/external/trigger_event_cm_viewed_test.php b/tests/external/trigger_event_cm_viewed_test.php new file mode 100644 index 0000000..e5c7b21 --- /dev/null +++ b/tests/external/trigger_event_cm_viewed_test.php @@ -0,0 +1,111 @@ +dirroot . '/local/adler/tests/lib/adler_testcase.php'); + +require_once($CFG->dirroot . '/webservice/tests/helpers.php'); + + +/** + * @runTestsInSeparateProcesses + */ +class trigger_event_cm_viewed_test extends adler_externallib_testcase { + public function setUp(): void { + parent::setUp(); + + // create user, course and enrol user + $this->user = $this->getDataGenerator()->create_user(); + $this->course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]); + $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id); + $this->setUser($this->user); + + // create resource module + $this->resource_module = $this->getDataGenerator()->create_module('resource', [ + 'course' => $this->course->id, + 'completion' => COMPLETION_TRACKING_AUTOMATIC, + 'completionview' => 1, + 'completionpassgrade' => 0 + ]); + + // create h5p with completionpassgrade + $this->h5p_module = $this->getDataGenerator()->create_module('h5pactivity', [ + 'course' => $this->course->id, + 'completion' => COMPLETION_TRACKING_AUTOMATIC, + 'completionview' => 0, + 'completionpassgrade' => 1, + 'completiongradeitemnumber' => 0 + ]); + } + + public function test_not_adler_course() { + $this->expectException(not_an_adler_course_exception::class); + + // call execute + trigger_event_cm_viewed::execute($this->resource_module->cmid, true); + } + + public function test_not_adler_cm() { + $this->expectException(not_an_adler_cm_exception::class); + + // make course adler course + $this + ->getDataGenerator() + ->get_plugin_generator('local_adler') + ->create_adler_course_object($this->course->id); + + // call execute + trigger_event_cm_viewed::execute($this->resource_module->cmid, true); + } + + public function test_execute_integration() { + // make course adler course + $this + ->getDataGenerator() + ->get_plugin_generator('local_adler') + ->create_adler_course_object($this->course->id); + + // make both modules adler modules + $resource_adler_cm = $this + ->getDataGenerator() + ->get_plugin_generator('local_adler') + ->create_adler_course_module($this->resource_module->cmid); + $h5p_adler_cm = $this + ->getDataGenerator() + ->get_plugin_generator('local_adler') + ->create_adler_course_module($this->h5p_module->cmid); + + // call execute for both modules + $result_resource = trigger_event_cm_viewed::execute($this->resource_module->cmid, true); + $result_h5p = trigger_event_cm_viewed::execute($this->h5p_module->cmid, true); + + // assert both modules are marked as viewed + $resource_cm_info = get_fast_modinfo($this->course->id)->get_cm($this->resource_module->cmid); + $resource_view_state = cm_completion_details::get_instance($resource_cm_info, $this->user->id); + $this->assertEquals(1, $resource_view_state->get_details()['completionview']->status); + $this->assertTrue($resource_view_state->is_overall_complete()); + + $h5p_cm_info = get_fast_modinfo($this->course->id)->get_cm($this->h5p_module->cmid); + $h5p_view_state = cm_completion_details::get_instance($h5p_cm_info, $this->user->id); +// $this->assertEquals(1, $h5p_view_state->get_details()['completionview']->status); // "viewed" is not tracked if it is not a completion criteria + $this->assertFalse($h5p_view_state->is_overall_complete()); + + // validate response structure via execute_returns + trigger_event_cm_viewed::validate_parameters(trigger_event_cm_viewed::execute_returns(), $result_resource); + trigger_event_cm_viewed::validate_parameters(trigger_event_cm_viewed::execute_returns(), $result_h5p); + + // validate responses + $this->assertEquals($resource_adler_cm->score_max, $result_resource['data']['0']['score']); + $this->assertEquals(0, $result_h5p['data']['0']['score']); + } +}