MDL-61667 analytics: Deprecate the add_builtin_models() method
[moodle.git] / analytics / tests / model_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 model.
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_shortname.php');
31 require_once(__DIR__ . '/fixtures/test_static_target_shortname.php');
32 require_once(__DIR__ . '/fixtures/test_target_course_level_shortname.php');
33 require_once(__DIR__ . '/fixtures/test_analyser.php');
35 /**
36  * Unit tests for the model.
37  *
38  * @package   core_analytics
39  * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
40  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
41  */
42 class analytics_model_testcase extends advanced_testcase {
44     public function setUp() {
46         $this->setAdminUser();
48         $target = \core_analytics\manager::get_target('test_target_shortname');
49         $indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
50         foreach ($indicators as $key => $indicator) {
51             $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
52         }
54         $this->model = testable_model::create($target, $indicators);
55         $this->modelobj = $this->model->get_model_obj();
56     }
58     public function test_enable() {
59         $this->resetAfterTest(true);
61         $this->assertEquals(0, $this->model->get_model_obj()->enabled);
62         $this->assertEquals(0, $this->model->get_model_obj()->trained);
63         $this->assertEquals('', $this->model->get_model_obj()->timesplitting);
65         $this->model->enable('\core\analytics\time_splitting\quarters');
66         $this->assertEquals(1, $this->model->get_model_obj()->enabled);
67         $this->assertEquals(0, $this->model->get_model_obj()->trained);
68         $this->assertEquals('\core\analytics\time_splitting\quarters', $this->model->get_model_obj()->timesplitting);
69     }
71     public function test_create() {
72         $this->resetAfterTest(true);
74         $target = \core_analytics\manager::get_target('\core\analytics\target\course_dropout');
75         $indicators = array(
76             \core_analytics\manager::get_indicator('\core\analytics\indicator\any_write_action'),
77             \core_analytics\manager::get_indicator('\core\analytics\indicator\read_actions')
78         );
79         $model = \core_analytics\model::create($target, $indicators);
80         $this->assertInstanceOf('\core_analytics\model', $model);
81     }
83     /**
84      * test_delete
85      */
86     public function test_delete() {
87         global $DB;
89         $this->resetAfterTest(true);
90         set_config('enabled_stores', 'logstore_standard', 'tool_log');
92         $coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
93         $coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
94         $coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
95         $coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
97         $this->model->enable('\core\analytics\time_splitting\no_splitting');
99         $this->model->train();
100         $this->model->predict();
102         // Fake evaluation results record to check that it is actually deleted.
103         $this->add_fake_log();
105         $modeloutputdir = $this->model->get_output_dir(array(), true);
106         $this->assertTrue(is_dir($modeloutputdir));
108         // Generate a prediction action to confirm that it is deleted when there is an important update.
109         $predictions = $DB->get_records('analytics_predictions');
110         $prediction = reset($predictions);
111         $prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used'));
112         $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
114         $this->model->delete();
115         $this->assertEmpty($DB->count_records('analytics_models', array('id' => $this->modelobj->id)));
116         $this->assertEmpty($DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id)));
117         $this->assertEmpty($DB->count_records('analytics_predictions'));
118         $this->assertEmpty($DB->count_records('analytics_prediction_actions'));
119         $this->assertEmpty($DB->count_records('analytics_train_samples'));
120         $this->assertEmpty($DB->count_records('analytics_predict_samples'));
121         $this->assertEmpty($DB->count_records('analytics_used_files'));
122         $this->assertFalse(is_dir($modeloutputdir));
124         set_config('enabled_stores', '', 'tool_log');
125         get_log_manager(true);
126     }
128     /**
129      * test_clear
130      */
131     public function test_clear() {
132         global $DB;
134         $this->resetAfterTest(true);
135         set_config('enabled_stores', 'logstore_standard', 'tool_log');
137         $coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
138         $coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
139         $coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
140         $coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
142         $this->model->enable('\core\analytics\time_splitting\no_splitting');
144         $this->model->train();
145         $this->model->predict();
147         // Fake evaluation results record to check that it is actually deleted.
148         $this->add_fake_log();
150         // Generate a prediction action to confirm that it is deleted when there is an important update.
151         $predictions = $DB->get_records('analytics_predictions');
152         $prediction = reset($predictions);
153         $prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used'));
154         $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
156         $modelversionoutputdir = $this->model->get_output_dir();
157         $this->assertTrue(is_dir($modelversionoutputdir));
159         // Update to an empty time splitting method to force model::clear execution.
160         $this->model->clear();
161         $this->assertFalse(is_dir($modelversionoutputdir));
163         // Check that most of the stuff got deleted.
164         $this->assertEquals(1, $DB->count_records('analytics_models', array('id' => $this->modelobj->id)));
165         $this->assertEquals(1, $DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id)));
166         $this->assertEmpty($DB->count_records('analytics_predictions'));
167         $this->assertEmpty($DB->count_records('analytics_prediction_actions'));
168         $this->assertEmpty($DB->count_records('analytics_train_samples'));
169         $this->assertEmpty($DB->count_records('analytics_predict_samples'));
170         $this->assertEmpty($DB->count_records('analytics_used_files'));
172         // Check that the model is marked as not trained after clearing (as it is not a static one).
173         $this->assertEquals(0, $DB->get_field('analytics_models', 'trained', array('id' => $this->modelobj->id)));
175         set_config('enabled_stores', '', 'tool_log');
176         get_log_manager(true);
177     }
179     /**
180      * Test behaviour of {\core_analytics\model::clear()} for static models.
181      */
182     public function test_clear_static() {
183         global $DB;
184         $this->resetAfterTest();
186         $statictarget = new test_static_target_shortname();
187         $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
188         $model = \core_analytics\model::create($statictarget, $indicators, '\core\analytics\time_splitting\quarters');
189         $modelobj = $model->get_model_obj();
191         // Static models are always considered trained.
192         $this->assertEquals(1, $DB->get_field('analytics_models', 'trained', array('id' => $modelobj->id)));
194         $model->clear();
196         // Check that the model is still marked as trained even after clearing.
197         $this->assertEquals(1, $DB->get_field('analytics_models', 'trained', array('id' => $modelobj->id)));
198     }
200     public function test_model_manager() {
201         $this->resetAfterTest(true);
203         $this->assertCount(3, $this->model->get_indicators());
204         $this->assertInstanceOf('\core_analytics\local\target\binary', $this->model->get_target());
206         // Using evaluation as the model is not yet enabled.
207         $this->model->init_analyser(array('evaluation' => true));
208         $this->assertInstanceOf('\core_analytics\local\analyser\base', $this->model->get_analyser());
210         $this->model->enable('\core\analytics\time_splitting\quarters');
211         $this->assertInstanceOf('\core\analytics\analyser\site_courses', $this->model->get_analyser());
212     }
214     public function test_output_dir() {
215         $this->resetAfterTest(true);
217         $dir = make_request_directory();
218         set_config('modeloutputdir', $dir, 'analytics');
220         $modeldir = $dir . DIRECTORY_SEPARATOR . $this->modelobj->id . DIRECTORY_SEPARATOR . $this->modelobj->version;
221         $this->assertEquals($modeldir, $this->model->get_output_dir());
222         $this->assertEquals($modeldir . DIRECTORY_SEPARATOR . 'testing', $this->model->get_output_dir(array('testing')));
223     }
225     public function test_unique_id() {
226         global $DB;
228         $this->resetAfterTest(true);
230         $originaluniqueid = $this->model->get_unique_id();
232         // Same id across instances.
233         $this->model = new testable_model($this->modelobj);
234         $this->assertEquals($originaluniqueid, $this->model->get_unique_id());
236         // We will restore it later.
237         $originalversion = $this->modelobj->version;
239         // Generates a different id if timemodified changes.
240         $this->modelobj->version = $this->modelobj->version + 10;
241         $DB->update_record('analytics_models', $this->modelobj);
242         $this->model = new testable_model($this->modelobj);
243         $this->assertNotEquals($originaluniqueid, $this->model->get_unique_id());
245         // Restore original timemodified to continue testing.
246         $this->modelobj->version = $originalversion;
247         $DB->update_record('analytics_models', $this->modelobj);
248         // Same when updating through an action that changes the model.
249         $this->model = new testable_model($this->modelobj);
251         $this->model->mark_as_trained();
252         $this->assertEquals($originaluniqueid, $this->model->get_unique_id());
254         // Wait for the current timestamp to change.
255         $this->waitForSecond();
256         $this->model->enable('\core\analytics\time_splitting\deciles');
257         $this->assertNotEquals($originaluniqueid, $this->model->get_unique_id());
258         $uniqueid = $this->model->get_unique_id();
260         // Wait for the current timestamp to change.
261         $this->waitForSecond();
262         $this->model->enable('\core\analytics\time_splitting\quarters');
263         $this->assertNotEquals($originaluniqueid, $this->model->get_unique_id());
264         $this->assertNotEquals($uniqueid, $this->model->get_unique_id());
265     }
267     /**
268      * test_exists
269      *
270      * @return void
271      */
272     public function test_exists() {
273         $this->resetAfterTest(true);
275         $target = \core_analytics\manager::get_target('\core\analytics\target\no_teaching');
276         $this->assertTrue(\core_analytics\model::exists($target));
277     }
279     /**
280      * test_model_timelimit
281      *
282      * @return null
283      */
284     public function test_model_timelimit() {
285         global $DB;
287         $this->resetAfterTest(true);
289         set_config('modeltimelimit', 2, 'analytics');
291         $courses = array();
292         for ($i = 0; $i < 5; $i++) {
293             $course = $this->getDataGenerator()->create_course();
294             $analysable = new \core_analytics\course($course);
295             $courses[$analysable->get_id()] = $course;
296         }
298         $target = new test_target_course_level_shortname();
299         $analyser = new test_analyser(1, $target, [], [], []);
301         // Each analysable element takes 1.1 secs, so the max (and likely) number of analysable
302         // elements that will be processed is 2.
303         $analyser->get_analysable_data(false);
304         $params = array('modelid' => 1, 'action' => 'prediction');
305         $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params));
307         $analyser->get_analysable_data(false);
308         $this->assertLessThanOrEqual(4, $DB->count_records('analytics_used_analysables', $params));
310         // Check that analysable elements have been processed following the analyser order
311         // (course->sortorder here). We can not check this nicely after next get_analysable_data round
312         // because the first analysed element will be analysed again.
313         $analysedelems = $DB->get_records('analytics_used_analysables', $params, 'timeanalysed ASC');
314         // Just a default for the first checked element.
315         $last = (object)['sortorder' => PHP_INT_MAX];
316         foreach ($analysedelems as $analysed) {
317             if ($courses[$analysed->analysableid]->sortorder > $last->sortorder) {
318                 $this->fail('Analysable elements have not been analysed sorted by course sortorder.');
319             }
320             $last = $courses[$analysed->analysableid];
321         }
323         $analyser->get_analysable_data(false);
324         $this->assertGreaterThanOrEqual(5, $DB->count_records('analytics_used_analysables', $params));
326         // New analysable elements are immediately pulled.
327         $this->getDataGenerator()->create_course();
328         $analyser->get_analysable_data(false);
329         $this->assertGreaterThanOrEqual(6, $DB->count_records('analytics_used_analysables', $params));
331         // Training and prediction data do not get mixed.
332         $analyser->get_analysable_data(true);
333         $params = array('modelid' => 1, 'action' => 'training');
334         $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params));
335     }
337     /**
338      * Test model_config::get_class_component.
339      */
340     public function test_model_config_get_class_component() {
341         $this->resetAfterTest(true);
343         $this->assertEquals('core',
344             \core_analytics\model_config::get_class_component('\\core\\analytics\\indicator\\read_actions'));
345         $this->assertEquals('core',
346             \core_analytics\model_config::get_class_component('core\\analytics\\indicator\\read_actions'));
347         $this->assertEquals('core',
348             \core_analytics\model_config::get_class_component('\\core_course\\analytics\\indicator\\completion_enabled'));
349         $this->assertEquals('mod_forum',
350             \core_analytics\model_config::get_class_component('\\mod_forum\\analytics\\indicator\\cognitive_depth'));
352         $this->assertEquals('core', \core_analytics\model_config::get_class_component('\\core_class'));
353     }
355     /**
356      * Test that import_model import models' configurations.
357      */
358     public function test_import_model_config() {
359         $this->resetAfterTest(true);
361         $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
362         $zipfilepath = $this->model->export_model('yeah-config.zip');
364         $this->modelobj = $this->model->get_model_obj();
366         $importedmodelobj = \core_analytics\model::import_model($zipfilepath)->get_model_obj();
368         $this->assertSame($this->modelobj->target, $importedmodelobj->target);
369         $this->assertSame($this->modelobj->indicators, $importedmodelobj->indicators);
370         $this->assertSame($this->modelobj->timesplitting, $importedmodelobj->timesplitting);
372         $predictionsprocessor = $this->model->get_predictions_processor();
373         $this->assertSame('\\' . get_class($predictionsprocessor), $importedmodelobj->predictionsprocessor);
374     }
376     /**
377      * Test can export configuration
378      */
379     public function test_can_export_configuration() {
380         $this->resetAfterTest(true);
382         // No time splitting method.
383         $this->assertFalse($this->model->can_export_configuration());
385         $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
386         $this->assertTrue($this->model->can_export_configuration());
388         $this->model->update(true, [], false);
389         $this->assertFalse($this->model->can_export_configuration());
391         $statictarget = new test_static_target_shortname();
392         $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
393         $model = \core_analytics\model::create($statictarget, $indicators, '\\core\\analytics\\time_splitting\\quarters');
394         $this->assertFalse($model->can_export_configuration());
395     }
397     /**
398      * Test export_config
399      */
400     public function test_export_config() {
401         $this->resetAfterTest(true);
403         $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
405         $modelconfig = new \core_analytics\model_config($this->model);
407         $method = new ReflectionMethod('\\core_analytics\\model_config', 'export_model_data');
408         $method->setAccessible(true);
410         $modeldata = $method->invoke($modelconfig);
412         $this->assertArrayHasKey('core', $modeldata->dependencies);
413         $this->assertInternalType('float', $modeldata->dependencies['core']);
414         $this->assertNotEmpty($modeldata->target);
415         $this->assertNotEmpty($modeldata->timesplitting);
416         $this->assertCount(3, $modeldata->indicators);
418         $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
419         $this->model->update(true, $indicators, false);
421         $modeldata = $method->invoke($modelconfig);
423         $this->assertCount(1, $modeldata->indicators);
424     }
426     /**
427      * Generates a model log record.
428      */
429     private function add_fake_log() {
430         global $DB, $USER;
432         $log = new stdClass();
433         $log->modelid = $this->modelobj->id;
434         $log->version = $this->modelobj->version;
435         $log->target = $this->modelobj->target;
436         $log->indicators = $this->modelobj->indicators;
437         $log->score = 1;
438         $log->info = json_encode([]);
439         $log->dir = 'not important';
440         $log->timecreated = time();
441         $log->usermodified = $USER->id;
442         $DB->insert_record('analytics_models_log', $log);
443     }
446 /**
447  * Testable version to change methods' visibility.
448  *
449  * @package   core_analytics
450  * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
451  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
452  */
453 class testable_model extends \core_analytics\model {
455     /**
456      * init_analyser
457      *
458      * @param array $options
459      * @return void
460      */
461     public function init_analyser($options = array()) {
462         parent::init_analyser($options);
463     }