Files
moodle/analytics/tests/manager_test.php
T
Eloy Lafuente (stronk7) 01148a0816 MDL-81522 phpunit: Add missing void return type to all tests
While this change is not 100% required now, it's good habit
and we are checking for it since Moodle 4.4.

All the changes in this commit have been applied automatically
using the moodle.PHPUnit.TestReturnType sniff and are, exclusively
adding the ": void" return types when missing.
2024-06-11 11:55:07 +02:00

555 lines
26 KiB
PHP

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/fixtures/test_indicator_max.php');
require_once(__DIR__ . '/fixtures/test_indicator_min.php');
require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
require_once(__DIR__ . '/fixtures/test_target_course_level_shortname.php');
/**
* Unit tests for the core_analytics manager.
*
* @package core_analytics
* @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core_analytics\manager
*/
class manager_test extends \advanced_testcase {
/**
* test_deleted_context
*/
public function test_deleted_context(): void {
global $DB;
$this->resetAfterTest(true);
$this->setAdminuser();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
$target = \core_analytics\manager::get_target('test_target_course_level_shortname');
$indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$model = \core_analytics\model::create($target, $indicators);
$modelobj = $model->get_model_obj();
$coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
$coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
$model->enable('\core\analytics\time_splitting\no_splitting');
$model->train();
$model->predict();
// Generate a prediction action to confirm that it is deleted when there is an important update.
$predictions = $DB->get_records('analytics_predictions');
$prediction = reset($predictions);
$prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used'));
$prediction->action_executed(\core_analytics\prediction::ACTION_USEFUL, $model->get_target());
$predictioncontextid = $prediction->get_prediction_data()->contextid;
$npredictions = $DB->count_records('analytics_predictions', array('contextid' => $predictioncontextid));
$npredictionactions = $DB->count_records('analytics_prediction_actions',
array('predictionid' => $prediction->get_prediction_data()->id));
$nindicatorcalc = $DB->count_records('analytics_indicator_calc', array('contextid' => $predictioncontextid));
\core_analytics\manager::cleanup();
// Nothing is incorrectly deleted.
$this->assertEquals($npredictions, $DB->count_records('analytics_predictions',
array('contextid' => $predictioncontextid)));
$this->assertEquals($npredictionactions, $DB->count_records('analytics_prediction_actions',
array('predictionid' => $prediction->get_prediction_data()->id)));
$this->assertEquals($nindicatorcalc, $DB->count_records('analytics_indicator_calc',
array('contextid' => $predictioncontextid)));
// Now we delete a context, the course predictions and prediction actions should be deleted.
$deletedcontext = \context::instance_by_id($predictioncontextid);
delete_course($deletedcontext->instanceid, false);
\core_analytics\manager::cleanup();
$this->assertEmpty($DB->count_records('analytics_predictions', array('contextid' => $predictioncontextid)));
$this->assertEmpty($DB->count_records('analytics_prediction_actions',
array('predictionid' => $prediction->get_prediction_data()->id)));
$this->assertEmpty($DB->count_records('analytics_indicator_calc', array('contextid' => $predictioncontextid)));
set_config('enabled_stores', '', 'tool_log');
get_log_manager(true);
}
/**
* test_deleted_analysable
*/
public function test_deleted_analysable(): void {
global $DB;
$this->resetAfterTest(true);
$this->setAdminuser();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
$target = \core_analytics\manager::get_target('test_target_course_level_shortname');
$indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$model = \core_analytics\model::create($target, $indicators);
$modelobj = $model->get_model_obj();
$coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
$coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
$model->enable('\core\analytics\time_splitting\no_splitting');
$model->train();
$model->predict();
$this->assertNotEmpty($DB->count_records('analytics_predict_samples'));
$this->assertNotEmpty($DB->count_records('analytics_train_samples'));
$this->assertNotEmpty($DB->count_records('analytics_used_analysables'));
// Now we delete an analysable, stored predict and training samples should be deleted.
$deletedcontext = \context_course::instance($coursepredict1->id);
delete_course($coursepredict1, false);
\core_analytics\manager::cleanup();
$this->assertEmpty($DB->count_records('analytics_predict_samples', array('analysableid' => $coursepredict1->id)));
$this->assertEmpty($DB->count_records('analytics_train_samples', array('analysableid' => $coursepredict1->id)));
$this->assertEmpty($DB->count_records('analytics_used_analysables', array('analysableid' => $coursepredict1->id)));
set_config('enabled_stores', '', 'tool_log');
get_log_manager(true);
}
/**
* Tests for the {@link \core_analytics\manager::load_default_models_for_component()} implementation.
*/
public function test_load_default_models_for_component(): void {
$this->resetAfterTest();
// Attempting to load builtin models should always work without throwing exception.
\core_analytics\manager::load_default_models_for_component('core');
// Attempting to load from a core subsystem without its own subsystem directory.
$this->assertSame([], \core_analytics\manager::load_default_models_for_component('core_access'));
// Attempting to load from a non-existing subsystem.
$this->assertSame([], \core_analytics\manager::load_default_models_for_component('core_nonexistingsubsystem'));
// Attempting to load from a non-existing plugin of a known plugin type.
$this->assertSame([], \core_analytics\manager::load_default_models_for_component('mod_foobarbazquaz12240996776'));
// Attempting to load from a non-existing plugin type.
$this->assertSame([], \core_analytics\manager::load_default_models_for_component('foo_bar2776327736558'));
}
/**
* Tests for the {@link \core_analytics\manager::load_default_models_for_all_components()} implementation.
*/
public function test_load_default_models_for_all_components(): void {
$this->resetAfterTest();
$models = \core_analytics\manager::load_default_models_for_all_components();
$this->assertTrue(is_array($models['core']));
$this->assertNotEmpty($models['core']);
$this->assertNotEmpty($models['core'][0]['target']);
$this->assertNotEmpty($models['core'][0]['indicators']);
}
/**
* Tests for the successful execution of the {@link \core_analytics\manager::validate_models_declaration()}.
*/
public function test_validate_models_declaration(): void {
$this->resetAfterTest();
// This is expected to run without an exception.
$models = $this->load_models_from_fixture_file('no_teaching');
\core_analytics\manager::validate_models_declaration($models);
}
/**
* Tests for the exceptions thrown by {@link \core_analytics\manager::validate_models_declaration()}.
*
* @dataProvider validate_models_declaration_exceptions_provider
* @param array $models Models declaration.
* @param string $exception Expected coding exception message.
*/
public function test_validate_models_declaration_exceptions(array $models, string $exception): void {
$this->resetAfterTest();
$this->expectException(\coding_exception::class);
$this->expectExceptionMessage($exception);
\core_analytics\manager::validate_models_declaration($models);
}
/**
* Data provider for the {@link self::test_validate_models_declaration_exceptions()}.
*
* @return array of (string)testcase => [(array)models, (string)expected exception message]
*/
public function validate_models_declaration_exceptions_provider() {
return [
'missing_target' => [
$this->load_models_from_fixture_file('missing_target'),
'Missing target declaration',
],
'invalid_target' => [
$this->load_models_from_fixture_file('invalid_target'),
'Invalid target classname',
],
'missing_indicators' => [
$this->load_models_from_fixture_file('missing_indicators'),
'Missing indicators declaration',
],
'invalid_indicators' => [
$this->load_models_from_fixture_file('invalid_indicators'),
'Invalid indicator classname',
],
'invalid_time_splitting' => [
$this->load_models_from_fixture_file('invalid_time_splitting'),
'Invalid time splitting classname',
],
'invalid_time_splitting_fq' => [
$this->load_models_from_fixture_file('invalid_time_splitting_fq'),
'Expecting fully qualified time splitting classname',
],
'invalid_enabled' => [
$this->load_models_from_fixture_file('invalid_enabled'),
'Cannot enable a model without time splitting method specified',
],
];
}
/**
* Loads models as declared in the given fixture file.
*
* @param string $filename
* @return array
*/
protected function load_models_from_fixture_file(string $filename) {
global $CFG;
$models = null;
require($CFG->dirroot.'/analytics/tests/fixtures/db_analytics_php/'.$filename.'.php');
return $models;
}
/**
* Test the implementation of the {@link \core_analytics\manager::create_declared_model()}.
*/
public function test_create_declared_model(): void {
global $DB;
$this->resetAfterTest();
$this->setAdminuser();
$declaration = [
'target' => 'test_target_course_level_shortname',
'indicators' => [
'test_indicator_max',
'test_indicator_min',
'test_indicator_fullname',
],
];
$declarationwithtimesplitting = array_merge($declaration, [
'timesplitting' => '\core\analytics\time_splitting\no_splitting',
]);
$declarationwithtimesplittingenabled = array_merge($declarationwithtimesplitting, [
'enabled' => true,
]);
// Check that no such model exists yet.
$target = \core_analytics\manager::get_target('test_target_course_level_shortname');
$this->assertEquals(0, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
$this->assertFalse(\core_analytics\model::exists($target));
// Check that the model is created.
$created = \core_analytics\manager::create_declared_model($declaration);
$this->assertTrue($created instanceof \core_analytics\model);
$this->assertTrue(\core_analytics\model::exists($target));
$this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
$modelid = $created->get_id();
// Check that created models are disabled by default.
$existing = new \core_analytics\model($modelid);
$this->assertEquals(0, $existing->get_model_obj()->enabled);
$this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
// Let the admin enable the model.
$existing->enable('\core\analytics\time_splitting\no_splitting');
$this->assertEquals(1, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
// Check that further calls create a new model.
$repeated = \core_analytics\manager::create_declared_model($declaration);
$this->assertTrue($repeated instanceof \core_analytics\model);
$this->assertEquals(2, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
// Delete the models.
$existing->delete();
$repeated->delete();
$this->assertEquals(0, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
$this->assertFalse(\core_analytics\model::exists($target));
// Create it again, this time with time splitting method specified.
$created = \core_analytics\manager::create_declared_model($declarationwithtimesplitting);
$this->assertTrue($created instanceof \core_analytics\model);
$this->assertTrue(\core_analytics\model::exists($target));
$this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
$modelid = $created->get_id();
// Even if the time splitting method was specified, the model is still not enabled automatically.
$existing = new \core_analytics\model($modelid);
$this->assertEquals(0, $existing->get_model_obj()->enabled);
$this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
$existing->delete();
// Let's define the model so that it is enabled by default.
$enabled = \core_analytics\manager::create_declared_model($declarationwithtimesplittingenabled);
$this->assertTrue($enabled instanceof \core_analytics\model);
$this->assertTrue(\core_analytics\model::exists($target));
$this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
$modelid = $enabled->get_id();
$existing = new \core_analytics\model($modelid);
$this->assertEquals(1, $existing->get_model_obj()->enabled);
$this->assertEquals(1, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
// Let the admin disable the model.
$existing->update(0, false, false);
$this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
}
/**
* Test the implementation of the {@link \core_analytics\manager::update_default_models_for_component()}.
*/
public function test_update_default_models_for_component(): void {
$this->resetAfterTest();
$this->setAdminuser();
$noteaching = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching');
$dropout = \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout');
$upcomingactivities = \core_analytics\manager::get_target('\core_user\analytics\target\upcoming_activities_due');
$norecentaccesses = \core_analytics\manager::get_target('\core_course\analytics\target\no_recent_accesses');
$noaccesssincestart = \core_analytics\manager::get_target('\core_course\analytics\target\no_access_since_course_start');
$this->assertTrue(\core_analytics\model::exists($noteaching));
$this->assertTrue(\core_analytics\model::exists($dropout));
$this->assertTrue(\core_analytics\model::exists($upcomingactivities));
$this->assertTrue(\core_analytics\model::exists($norecentaccesses));
$this->assertTrue(\core_analytics\model::exists($noaccesssincestart));
foreach (\core_analytics\manager::get_all_models() as $model) {
$model->delete();
}
$this->assertFalse(\core_analytics\model::exists($noteaching));
$this->assertFalse(\core_analytics\model::exists($dropout));
$this->assertFalse(\core_analytics\model::exists($upcomingactivities));
$this->assertFalse(\core_analytics\model::exists($norecentaccesses));
$this->assertFalse(\core_analytics\model::exists($noaccesssincestart));
$updated = \core_analytics\manager::update_default_models_for_component('moodle');
$this->assertEquals(5, count($updated));
$this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
$this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
$this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
$this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
$this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
$this->assertTrue(\core_analytics\model::exists($noteaching));
$this->assertTrue(\core_analytics\model::exists($dropout));
$this->assertTrue(\core_analytics\model::exists($upcomingactivities));
$this->assertTrue(\core_analytics\model::exists($norecentaccesses));
$this->assertTrue(\core_analytics\model::exists($noaccesssincestart));
$repeated = \core_analytics\manager::update_default_models_for_component('moodle');
$this->assertSame([], $repeated);
}
/**
* test_get_time_splitting_methods description
* @return null
*/
public function test_get_time_splitting_methods(): void {
$this->resetAfterTest(true);
$all = \core_analytics\manager::get_all_time_splittings();
$this->assertArrayHasKey('\core\analytics\time_splitting\upcoming_week', $all);
$this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $all);
$allforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(true);
$this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $allforevaluation);
$this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $allforevaluation);
$defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false);
$this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $defaultforevaluation);
$this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation);
$sometimesplittings = '\core\analytics\time_splitting\single_range,' .
'\core\analytics\time_splitting\tenths';
set_config('defaulttimesplittingsevaluation', $sometimesplittings, 'analytics');
$defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false);
$this->assertArrayNotHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation);
}
/**
* Test the implementation of the {@link \core_analytics\manager::model_declaration_identifier()}.
*/
public function test_model_declaration_identifier(): void {
$noteaching1 = $this->load_models_from_fixture_file('no_teaching');
$noteaching2 = $this->load_models_from_fixture_file('no_teaching');
$noteaching3 = $this->load_models_from_fixture_file('no_teaching');
// Same model declaration should always lead to same identifier.
$this->assertEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
\core_analytics\manager::model_declaration_identifier(reset($noteaching2))
);
// If something is changed, the identifier should change, too.
$noteaching2[0]['target'] .= '_';
$this->assertNotEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
\core_analytics\manager::model_declaration_identifier(reset($noteaching2))
);
$noteaching3[0]['indicators'][] = '\core_analytics\local\indicator\binary';
$this->assertNotEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
\core_analytics\manager::model_declaration_identifier(reset($noteaching3))
);
// The identifier is supposed to contain PARAM_ALPHANUM only.
$this->assertEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching1)), PARAM_ALPHANUM)
);
$this->assertEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching2)),
clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching2)), PARAM_ALPHANUM)
);
$this->assertEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching3)),
clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching3)), PARAM_ALPHANUM)
);
}
/**
* Tests for the {@link \core_analytics\manager::get_declared_target_and_indicators_instances()}.
*/
public function test_get_declared_target_and_indicators_instances(): void {
$this->resetAfterTest();
$definition = $this->load_models_from_fixture_file('no_teaching');
list($target, $indicators) = \core_analytics\manager::get_declared_target_and_indicators_instances($definition[0]);
$this->assertTrue($target instanceof \core_analytics\local\target\base);
$this->assertNotEmpty($indicators);
$this->assertContainsOnlyInstancesOf(\core_analytics\local\indicator\base::class, $indicators);
}
/**
* test_get_potential_context_restrictions description
*/
public function test_get_potential_context_restrictions(): void {
$this->resetAfterTest();
// No potential context restrictions.
$this->assertFalse(\core_analytics\manager::get_potential_context_restrictions([]));
$defaultcategory = \core_course_category::get_default();
$defaultcategorycontext = $defaultcategory->get_context();
// Include the all context levels so the misc. category get included.
$this->assertEquals([
$defaultcategorycontext->id => "Category: {$defaultcategory->name}",
], manager::get_potential_context_restrictions());
$category = $this->getDataGenerator()->create_category(['name' => 'My category']);
$categorycontext = $category->get_context();
$courseone = $this->getDataGenerator()->create_course(['fullname' => 'Course one', 'shortname' => 'CS1']);
$courseonecontext = \context_course::instance($courseone->id);
$coursetwo = $this->getDataGenerator()->create_course(['fullname' => 'Course two', 'shortname' => 'CS2']);
$coursetwocontext = \context_course::instance($coursetwo->id);
// All context levels.
$this->assertEqualsCanonicalizing([
$defaultcategorycontext->id => "Category: {$defaultcategory->name}",
$categorycontext->id => "Category: {$category->name}",
$courseonecontext->id => "Course: {$courseone->shortname}",
$coursetwocontext->id => "Course: {$coursetwo->shortname}",
], manager::get_potential_context_restrictions());
// All category/course context levels.
$this->assertEqualsCanonicalizing([
$defaultcategorycontext->id => "Category: {$defaultcategory->name}",
$categorycontext->id => "Category: {$category->name}",
$courseonecontext->id => "Course: {$courseone->shortname}",
$coursetwocontext->id => "Course: {$coursetwo->shortname}",
], manager::get_potential_context_restrictions([CONTEXT_COURSECAT, CONTEXT_COURSE]));
// All category context levels.
$this->assertEqualsCanonicalizing([
$defaultcategorycontext->id => "Category: {$defaultcategory->name}",
$categorycontext->id => "Category: {$category->name}",
], manager::get_potential_context_restrictions([CONTEXT_COURSECAT]));
// Filtered category context levels.
$this->assertEquals([
$categorycontext->id => "Category: {$category->name}",
], manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'My cat'));
$this->assertEmpty(manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'nothing'));
// All course context levels.
$this->assertEqualsCanonicalizing([
$courseonecontext->id => "Course: {$courseone->shortname}",
$coursetwocontext->id => "Course: {$coursetwo->shortname}",
], manager::get_potential_context_restrictions([CONTEXT_COURSE]));
// Filtered course context levels.
$this->assertEquals([
$courseonecontext->id => "Course: {$courseone->shortname}",
], manager::get_potential_context_restrictions([CONTEXT_COURSE], 'one'));
$this->assertEmpty(manager::get_potential_context_restrictions([CONTEXT_COURSE], 'nothing'));
}
}