28d506796ec419c6ca58279a619768d91c60251e
[moodle.git] / analytics / tests / manager_test.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Unit tests for the manager.
19  *
20  * @package   core_analytics
21  * @copyright 2017 David MonllaĆ³ {@link http://www.davidmonllao.com}
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 require_once(__DIR__ . '/fixtures/test_indicator_max.php');
28 require_once(__DIR__ . '/fixtures/test_indicator_min.php');
29 require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
30 require_once(__DIR__ . '/fixtures/test_target_course_level_shortname.php');
32 /**
33  * Unit tests for the manager.
34  *
35  * @package   core_analytics
36  * @copyright 2017 David MonllaĆ³ {@link http://www.davidmonllao.com}
37  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38  */
39 class analytics_manager_testcase extends advanced_testcase {
41     /**
42      * test_deleted_context
43      */
44     public function test_deleted_context() {
45         global $DB;
47         $this->resetAfterTest(true);
48         $this->setAdminuser();
49         set_config('enabled_stores', 'logstore_standard', 'tool_log');
51         $target = \core_analytics\manager::get_target('test_target_course_level_shortname');
52         $indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
53         foreach ($indicators as $key => $indicator) {
54             $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
55         }
57         $model = \core_analytics\model::create($target, $indicators);
58         $modelobj = $model->get_model_obj();
60         $coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
61         $coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
62         $coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
63         $coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
65         $model->enable('\core\analytics\time_splitting\no_splitting');
67         $model->train();
68         $model->predict();
70         // Generate a prediction action to confirm that it is deleted when there is an important update.
71         $predictions = $DB->get_records('analytics_predictions');
72         $prediction = reset($predictions);
73         $prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used'));
74         $prediction->action_executed(\core_analytics\prediction::ACTION_USEFUL, $model->get_target());
76         $predictioncontextid = $prediction->get_prediction_data()->contextid;
78         $npredictions = $DB->count_records('analytics_predictions', array('contextid' => $predictioncontextid));
79         $npredictionactions = $DB->count_records('analytics_prediction_actions',
80             array('predictionid' => $prediction->get_prediction_data()->id));
81         $nindicatorcalc = $DB->count_records('analytics_indicator_calc', array('contextid' => $predictioncontextid));
83         \core_analytics\manager::cleanup();
85         // Nothing is incorrectly deleted.
86         $this->assertEquals($npredictions, $DB->count_records('analytics_predictions',
87             array('contextid' => $predictioncontextid)));
88         $this->assertEquals($npredictionactions, $DB->count_records('analytics_prediction_actions',
89             array('predictionid' => $prediction->get_prediction_data()->id)));
90         $this->assertEquals($nindicatorcalc, $DB->count_records('analytics_indicator_calc',
91             array('contextid' => $predictioncontextid)));
93         // Now we delete a context, the course predictions and prediction actions should be deleted.
94         $deletedcontext = \context::instance_by_id($predictioncontextid);
95         delete_course($deletedcontext->instanceid, false);
97         \core_analytics\manager::cleanup();
99         $this->assertEmpty($DB->count_records('analytics_predictions', array('contextid' => $predictioncontextid)));
100         $this->assertEmpty($DB->count_records('analytics_prediction_actions',
101             array('predictionid' => $prediction->get_prediction_data()->id)));
102         $this->assertEmpty($DB->count_records('analytics_indicator_calc', array('contextid' => $predictioncontextid)));
104         set_config('enabled_stores', '', 'tool_log');
105         get_log_manager(true);
106     }
108     /**
109      * test_deleted_analysable
110      */
111     public function test_deleted_analysable() {
112         global $DB;
114         $this->resetAfterTest(true);
115         $this->setAdminuser();
116         set_config('enabled_stores', 'logstore_standard', 'tool_log');
118         $target = \core_analytics\manager::get_target('test_target_course_level_shortname');
119         $indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
120         foreach ($indicators as $key => $indicator) {
121             $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
122         }
124         $model = \core_analytics\model::create($target, $indicators);
125         $modelobj = $model->get_model_obj();
127         $coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
128         $coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
129         $coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
130         $coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
132         $model->enable('\core\analytics\time_splitting\no_splitting');
134         $model->train();
135         $model->predict();
137         $this->assertNotEmpty($DB->count_records('analytics_predict_samples'));
138         $this->assertNotEmpty($DB->count_records('analytics_train_samples'));
139         $this->assertNotEmpty($DB->count_records('analytics_used_analysables'));
141         // Now we delete an analysable, stored predict and training samples should be deleted.
142         $deletedcontext = \context_course::instance($coursepredict1->id);
143         delete_course($coursepredict1, false);
145         \core_analytics\manager::cleanup();
147         $this->assertEmpty($DB->count_records('analytics_predict_samples', array('analysableid' => $coursepredict1->id)));
148         $this->assertEmpty($DB->count_records('analytics_train_samples', array('analysableid' => $coursepredict1->id)));
149         $this->assertEmpty($DB->count_records('analytics_used_analysables', array('analysableid' => $coursepredict1->id)));
151         set_config('enabled_stores', '', 'tool_log');
152         get_log_manager(true);
153     }
155     /**
156      * Tests for the {@link \core_analytics\manager::load_default_models_for_component()} implementation.
157      */
158     public function test_load_default_models_for_component() {
159         $this->resetAfterTest();
161         // Attempting to load builtin models should always work without throwing exception.
162         \core_analytics\manager::load_default_models_for_component('core');
164         // Attempting to load from a core subsystem without its own subsystem directory.
165         $this->assertSame([], \core_analytics\manager::load_default_models_for_component('core_access'));
167         // Attempting to load from a non-existing subsystem.
168         $this->assertSame([], \core_analytics\manager::load_default_models_for_component('core_nonexistingsubsystem'));
170         // Attempting to load from a non-existing plugin of a known plugin type.
171         $this->assertSame([], \core_analytics\manager::load_default_models_for_component('mod_foobarbazquaz12240996776'));
173         // Attempting to load from a non-existing plugin type.
174         $this->assertSame([], \core_analytics\manager::load_default_models_for_component('foo_bar2776327736558'));
175     }
177     /**
178      * Tests for the {@link \core_analytics\manager::load_default_models_for_all_components()} implementation.
179      */
180     public function test_load_default_models_for_all_components() {
181         $this->resetAfterTest();
183         $models = \core_analytics\manager::load_default_models_for_all_components();
185         $this->assertTrue(is_array($models['core']));
186         $this->assertNotEmpty($models['core']);
187         $this->assertNotEmpty($models['core'][0]['target']);
188         $this->assertNotEmpty($models['core'][0]['indicators']);
189     }
191     /**
192      * Tests for the successful execution of the {@link \core_analytics\manager::validate_models_declaration()}.
193      */
194     public function test_validate_models_declaration() {
195         $this->resetAfterTest();
197         // This is expected to run without an exception.
198         $models = $this->load_models_from_fixture_file('no_teaching');
199         \core_analytics\manager::validate_models_declaration($models);
200     }
202     /**
203      * Tests for the exceptions thrown by {@link \core_analytics\manager::validate_models_declaration()}.
204      *
205      * @dataProvider validate_models_declaration_exceptions_provider
206      * @param array $models Models declaration.
207      * @param string $exception Expected coding exception message.
208      */
209     public function test_validate_models_declaration_exceptions(array $models, string $exception) {
210         $this->resetAfterTest();
212         $this->expectException(\coding_exception::class);
213         $this->expectExceptionMessage($exception);
214         \core_analytics\manager::validate_models_declaration($models);
215     }
217     /**
218      * Data provider for the {@link self::test_validate_models_declaration_exceptions()}.
219      *
220      * @return array of (string)testcase => [(array)models, (string)expected exception message]
221      */
222     public function validate_models_declaration_exceptions_provider() {
223         return [
224             'missing_target' => [
225                 $this->load_models_from_fixture_file('missing_target'),
226                 'Missing target declaration',
227             ],
228             'invalid_target' => [
229                 $this->load_models_from_fixture_file('invalid_target'),
230                 'Invalid target classname',
231             ],
232             'missing_indicators' => [
233                 $this->load_models_from_fixture_file('missing_indicators'),
234                 'Missing indicators declaration',
235             ],
236             'invalid_indicators' => [
237                 $this->load_models_from_fixture_file('invalid_indicators'),
238                 'Invalid indicator classname',
239             ],
240             'invalid_time_splitting' => [
241                 $this->load_models_from_fixture_file('invalid_time_splitting'),
242                 'Invalid time splitting classname',
243             ],
244             'invalid_time_splitting_fq' => [
245                 $this->load_models_from_fixture_file('invalid_time_splitting_fq'),
246                 'Expecting fully qualified time splitting classname',
247             ],
248             'invalid_enabled' => [
249                 $this->load_models_from_fixture_file('invalid_enabled'),
250                 'Cannot enable a model without time splitting method specified',
251             ],
252         ];
253     }
255     /**
256      * Loads models as declared in the given fixture file.
257      *
258      * @param string $filename
259      * @return array
260      */
261     protected function load_models_from_fixture_file(string $filename) {
262         global $CFG;
264         $models = null;
266         require($CFG->dirroot.'/analytics/tests/fixtures/db_analytics_php/'.$filename.'.php');
268         return $models;
269     }
271     /**
272      * Test the implementation of the {@link \core_analytics\manager::create_declared_model()}.
273      */
274     public function test_create_declared_model() {
275         global $DB;
277         $this->resetAfterTest();
278         $this->setAdminuser();
280         $declaration = [
281             'target' => 'test_target_course_level_shortname',
282             'indicators' => [
283                 'test_indicator_max',
284                 'test_indicator_min',
285                 'test_indicator_fullname',
286             ],
287         ];
289         $declarationwithtimesplitting = array_merge($declaration, [
290             'timesplitting' => '\core\analytics\time_splitting\no_splitting',
291         ]);
293         $declarationwithtimesplittingenabled = array_merge($declarationwithtimesplitting, [
294             'enabled' => true,
295         ]);
297         // Check that no such model exists yet.
298         $target = \core_analytics\manager::get_target('test_target_course_level_shortname');
299         $this->assertEquals(0, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
300         $this->assertFalse(\core_analytics\model::exists($target));
302         // Check that the model is created.
303         $created = \core_analytics\manager::create_declared_model($declaration);
304         $this->assertTrue($created instanceof \core_analytics\model);
305         $this->assertTrue(\core_analytics\model::exists($target));
306         $this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
307         $modelid = $created->get_id();
309         // Check that created models are disabled by default.
310         $existing = new \core_analytics\model($modelid);
311         $this->assertEquals(0, $existing->get_model_obj()->enabled);
312         $this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
314         // Let the admin enable the model.
315         $existing->enable('\core\analytics\time_splitting\no_splitting');
316         $this->assertEquals(1, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
318         // Check that further calls create a new model.
319         $repeated = \core_analytics\manager::create_declared_model($declaration);
320         $this->assertTrue($repeated instanceof \core_analytics\model);
321         $this->assertEquals(2, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
323         // Delete the models.
324         $existing->delete();
325         $repeated->delete();
326         $this->assertEquals(0, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
327         $this->assertFalse(\core_analytics\model::exists($target));
329         // Create it again, this time with time splitting method specified.
330         $created = \core_analytics\manager::create_declared_model($declarationwithtimesplitting);
331         $this->assertTrue($created instanceof \core_analytics\model);
332         $this->assertTrue(\core_analytics\model::exists($target));
333         $this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
334         $modelid = $created->get_id();
336         // Even if the time splitting method was specified, the model is still not enabled automatically.
337         $existing = new \core_analytics\model($modelid);
338         $this->assertEquals(0, $existing->get_model_obj()->enabled);
339         $this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
340         $existing->delete();
342         // Let's define the model so that it is enabled by default.
343         $enabled = \core_analytics\manager::create_declared_model($declarationwithtimesplittingenabled);
344         $this->assertTrue($enabled instanceof \core_analytics\model);
345         $this->assertTrue(\core_analytics\model::exists($target));
346         $this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
347         $modelid = $enabled->get_id();
348         $existing = new \core_analytics\model($modelid);
349         $this->assertEquals(1, $existing->get_model_obj()->enabled);
350         $this->assertEquals(1, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
352         // Let the admin disable the model.
353         $existing->update(0, false, false);
354         $this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
355     }
357     /**
358      * Test the implementation of the {@link \core_analytics\manager::update_default_models_for_component()}.
359      */
360     public function test_update_default_models_for_component() {
362         $this->resetAfterTest();
363         $this->setAdminuser();
365         $noteaching = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching');
366         $dropout = \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout');
367         $upcomingactivities = \core_analytics\manager::get_target('\core_user\analytics\target\upcoming_activities_due');
368         $norecentaccesses = \core_analytics\manager::get_target('\core_course\analytics\target\no_recent_accesses');
369         $noaccesssincestart = \core_analytics\manager::get_target('\core_course\analytics\target\no_access_since_course_start');
371         $this->assertTrue(\core_analytics\model::exists($noteaching));
372         $this->assertTrue(\core_analytics\model::exists($dropout));
373         $this->assertTrue(\core_analytics\model::exists($upcomingactivities));
374         $this->assertTrue(\core_analytics\model::exists($norecentaccesses));
375         $this->assertTrue(\core_analytics\model::exists($noaccesssincestart));
377         foreach (\core_analytics\manager::get_all_models() as $model) {
378             $model->delete();
379         }
381         $this->assertFalse(\core_analytics\model::exists($noteaching));
382         $this->assertFalse(\core_analytics\model::exists($dropout));
383         $this->assertFalse(\core_analytics\model::exists($upcomingactivities));
384         $this->assertFalse(\core_analytics\model::exists($norecentaccesses));
385         $this->assertFalse(\core_analytics\model::exists($noaccesssincestart));
387         $updated = \core_analytics\manager::update_default_models_for_component('moodle');
389         $this->assertEquals(5, count($updated));
390         $this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
391         $this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
392         $this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
393         $this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
394         $this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
395         $this->assertTrue(\core_analytics\model::exists($noteaching));
396         $this->assertTrue(\core_analytics\model::exists($dropout));
397         $this->assertTrue(\core_analytics\model::exists($upcomingactivities));
398         $this->assertTrue(\core_analytics\model::exists($norecentaccesses));
399         $this->assertTrue(\core_analytics\model::exists($noaccesssincestart));
401         $repeated = \core_analytics\manager::update_default_models_for_component('moodle');
403         $this->assertSame([], $repeated);
404     }
406     /**
407      * test_get_time_splitting_methods description
408      * @return null
409      */
410     public function test_get_time_splitting_methods() {
411         $this->resetAfterTest(true);
413         $all = \core_analytics\manager::get_all_time_splittings();
414         $this->assertArrayHasKey('\core\analytics\time_splitting\upcoming_week', $all);
415         $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $all);
417         $allforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(true);
418         $this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $allforevaluation);
419         $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $allforevaluation);
421         $defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false);
422         $this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $defaultforevaluation);
423         $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation);
425         $sometimesplittings = '\core\analytics\time_splitting\single_range,' .
426             '\core\analytics\time_splitting\tenths';
427         set_config('defaulttimesplittingsevaluation', $sometimesplittings, 'analytics');
429         $defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false);
430         $this->assertArrayNotHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation);
431     }
433     /**
434      * Test the implementation of the {@link \core_analytics\manager::model_declaration_identifier()}.
435      */
436     public function test_model_declaration_identifier() {
438         $noteaching1 = $this->load_models_from_fixture_file('no_teaching');
439         $noteaching2 = $this->load_models_from_fixture_file('no_teaching');
440         $noteaching3 = $this->load_models_from_fixture_file('no_teaching');
442         // Same model declaration should always lead to same identifier.
443         $this->assertEquals(
444             \core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
445             \core_analytics\manager::model_declaration_identifier(reset($noteaching2))
446         );
448         // If something is changed, the identifier should change, too.
449         $noteaching2[0]['target'] .= '_';
450         $this->assertNotEquals(
451             \core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
452             \core_analytics\manager::model_declaration_identifier(reset($noteaching2))
453         );
455         $noteaching3[0]['indicators'][] = '\core_analytics\local\indicator\binary';
456         $this->assertNotEquals(
457             \core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
458             \core_analytics\manager::model_declaration_identifier(reset($noteaching3))
459         );
461         // The identifier is supposed to contain PARAM_ALPHANUM only.
462         $this->assertEquals(
463             \core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
464             clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching1)), PARAM_ALPHANUM)
465         );
466         $this->assertEquals(
467             \core_analytics\manager::model_declaration_identifier(reset($noteaching2)),
468             clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching2)), PARAM_ALPHANUM)
469         );
470         $this->assertEquals(
471             \core_analytics\manager::model_declaration_identifier(reset($noteaching3)),
472             clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching3)), PARAM_ALPHANUM)
473         );
474     }
476     /**
477      * Tests for the {@link \core_analytics\manager::get_declared_target_and_indicators_instances()}.
478      */
479     public function test_get_declared_target_and_indicators_instances() {
480         $this->resetAfterTest();
482         $definition = $this->load_models_from_fixture_file('no_teaching');
484         list($target, $indicators) = \core_analytics\manager::get_declared_target_and_indicators_instances($definition[0]);
486         $this->assertTrue($target instanceof \core_analytics\local\target\base);
487         $this->assertNotEmpty($indicators);
488         $this->assertContainsOnlyInstancesOf(\core_analytics\local\indicator\base::class, $indicators);
489     }
491     /**
492      * test_get_potential_context_restrictions description
493      */
494     public function test_get_potential_context_restrictions() {
495         $this->resetAfterTest();
497         // No potential context restrictions.
498         $this->assertFalse(\core_analytics\manager::get_potential_context_restrictions([]));
500         // Include the all context levels so the frontpage and the misc. category get included.
501         $this->assertCount(2, \core_analytics\manager::get_potential_context_restrictions());
503         $this->getDataGenerator()->create_course();
504         $this->getDataGenerator()->create_category();
505         $this->assertCount(4, \core_analytics\manager::get_potential_context_restrictions());
506         $this->assertCount(4, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE, CONTEXT_COURSECAT]));
508         $this->assertCount(2, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE]));
509         $this->assertCount(2, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT]));
510     }