2fc0fa97c060d4bfab24ed114313ba0328fe9ec1
[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         global $DB;
277         $count = $DB->count_records('analytics_models');
279         // No new models added if the builtin ones already exist.
280         \core_analytics\manager::add_builtin_models();
281         $this->assertCount($count, $DB->get_records('analytics_models'));
283         $target = \core_analytics\manager::get_target('\core\analytics\target\no_teaching');
284         $this->assertTrue(\core_analytics\model::exists($target));
285     }
287     /**
288      * test_model_timelimit
289      *
290      * @return null
291      */
292     public function test_model_timelimit() {
293         global $DB;
295         $this->resetAfterTest(true);
297         set_config('modeltimelimit', 2, 'analytics');
299         $courses = array();
300         for ($i = 0; $i < 5; $i++) {
301             $course = $this->getDataGenerator()->create_course();
302             $analysable = new \core_analytics\course($course);
303             $courses[$analysable->get_id()] = $course;
304         }
306         $target = new test_target_course_level_shortname();
307         $analyser = new test_analyser(1, $target, [], [], []);
309         // Each analysable element takes 1.1 secs, so the max (and likely) number of analysable
310         // elements that will be processed is 2.
311         $analyser->get_analysable_data(false);
312         $params = array('modelid' => 1, 'action' => 'prediction');
313         $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params));
315         $analyser->get_analysable_data(false);
316         $this->assertLessThanOrEqual(4, $DB->count_records('analytics_used_analysables', $params));
318         // Check that analysable elements have been processed following the analyser order
319         // (course->sortorder here). We can not check this nicely after next get_analysable_data round
320         // because the first analysed element will be analysed again.
321         $analysedelems = $DB->get_records('analytics_used_analysables', $params, 'timeanalysed ASC');
322         // Just a default for the first checked element.
323         $last = (object)['sortorder' => PHP_INT_MAX];
324         foreach ($analysedelems as $analysed) {
325             if ($courses[$analysed->analysableid]->sortorder > $last->sortorder) {
326                 $this->fail('Analysable elements have not been analysed sorted by course sortorder.');
327             }
328             $last = $courses[$analysed->analysableid];
329         }
331         $analyser->get_analysable_data(false);
332         $this->assertGreaterThanOrEqual(5, $DB->count_records('analytics_used_analysables', $params));
334         // New analysable elements are immediately pulled.
335         $this->getDataGenerator()->create_course();
336         $analyser->get_analysable_data(false);
337         $this->assertGreaterThanOrEqual(6, $DB->count_records('analytics_used_analysables', $params));
339         // Training and prediction data do not get mixed.
340         $analyser->get_analysable_data(true);
341         $params = array('modelid' => 1, 'action' => 'training');
342         $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params));
343     }
345     /**
346      * Test model_config::get_class_component.
347      */
348     public function test_model_config_get_class_component() {
349         $this->resetAfterTest(true);
351         $this->assertEquals('core',
352             \core_analytics\model_config::get_class_component('\\core\\analytics\\indicator\\read_actions'));
353         $this->assertEquals('core',
354             \core_analytics\model_config::get_class_component('core\\analytics\\indicator\\read_actions'));
355         $this->assertEquals('core',
356             \core_analytics\model_config::get_class_component('\\core_course\\analytics\\indicator\\completion_enabled'));
357         $this->assertEquals('mod_forum',
358             \core_analytics\model_config::get_class_component('\\mod_forum\\analytics\\indicator\\cognitive_depth'));
360         $this->assertEquals('core', \core_analytics\model_config::get_class_component('\\core_class'));
361     }
363     /**
364      * Test that import_model import models' configurations.
365      */
366     public function test_import_model_config() {
367         $this->resetAfterTest(true);
369         $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
370         $zipfilepath = $this->model->export_model('yeah-config.zip');
372         $this->modelobj = $this->model->get_model_obj();
374         $importedmodelobj = \core_analytics\model::import_model($zipfilepath)->get_model_obj();
376         $this->assertSame($this->modelobj->target, $importedmodelobj->target);
377         $this->assertSame($this->modelobj->indicators, $importedmodelobj->indicators);
378         $this->assertSame($this->modelobj->timesplitting, $importedmodelobj->timesplitting);
380         $predictionsprocessor = $this->model->get_predictions_processor();
381         $this->assertSame('\\' . get_class($predictionsprocessor), $importedmodelobj->predictionsprocessor);
382     }
384     /**
385      * Test can export configuration
386      */
387     public function test_can_export_configuration() {
388         $this->resetAfterTest(true);
390         // No time splitting method.
391         $this->assertFalse($this->model->can_export_configuration());
393         $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
394         $this->assertTrue($this->model->can_export_configuration());
396         $this->model->update(true, [], false);
397         $this->assertFalse($this->model->can_export_configuration());
399         $statictarget = new test_static_target_shortname();
400         $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
401         $model = \core_analytics\model::create($statictarget, $indicators, '\\core\\analytics\\time_splitting\\quarters');
402         $this->assertFalse($model->can_export_configuration());
403     }
405     /**
406      * Test export_config
407      */
408     public function test_export_config() {
409         $this->resetAfterTest(true);
411         $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
413         $modelconfig = new \core_analytics\model_config($this->model);
415         $method = new ReflectionMethod('\\core_analytics\\model_config', 'export_model_data');
416         $method->setAccessible(true);
418         $modeldata = $method->invoke($modelconfig);
420         $this->assertArrayHasKey('core', $modeldata->dependencies);
421         $this->assertInternalType('float', $modeldata->dependencies['core']);
422         $this->assertNotEmpty($modeldata->target);
423         $this->assertNotEmpty($modeldata->timesplitting);
424         $this->assertCount(3, $modeldata->indicators);
426         $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
427         $this->model->update(true, $indicators, false);
429         $modeldata = $method->invoke($modelconfig);
431         $this->assertCount(1, $modeldata->indicators);
432     }
434     /**
435      * Generates a model log record.
436      */
437     private function add_fake_log() {
438         global $DB, $USER;
440         $log = new stdClass();
441         $log->modelid = $this->modelobj->id;
442         $log->version = $this->modelobj->version;
443         $log->target = $this->modelobj->target;
444         $log->indicators = $this->modelobj->indicators;
445         $log->score = 1;
446         $log->info = json_encode([]);
447         $log->dir = 'not important';
448         $log->timecreated = time();
449         $log->usermodified = $USER->id;
450         $DB->insert_record('analytics_models_log', $log);
451     }
454 /**
455  * Testable version to change methods' visibility.
456  *
457  * @package   core_analytics
458  * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
459  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
460  */
461 class testable_model extends \core_analytics\model {
463     /**
464      * init_analyser
465      *
466      * @param array $options
467      * @return void
468      */
469     public function init_analyser($options = array()) {
470         parent::init_analyser($options);
471     }