MDL-61667 analytics: Fix checking that a given model does not exist
[moodle.git] / analytics / tests / model_test.php
CommitLineData
ff656bae
DM
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/>.
16
17/**
18 * Unit tests for the model.
19 *
413f19bc 20 * @package core_analytics
ff656bae
DM
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 */
24
25defined('MOODLE_INTERNAL') || die();
26
27require_once(__DIR__ . '/fixtures/test_indicator_max.php');
28require_once(__DIR__ . '/fixtures/test_indicator_min.php');
29require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
30require_once(__DIR__ . '/fixtures/test_target_shortname.php');
e4453adc 31require_once(__DIR__ . '/fixtures/test_static_target_shortname.php');
dd13fc22
DM
32require_once(__DIR__ . '/fixtures/test_target_course_level_shortname.php');
33require_once(__DIR__ . '/fixtures/test_analyser.php');
ff656bae
DM
34
35/**
36 * Unit tests for the model.
37 *
413f19bc 38 * @package core_analytics
ff656bae
DM
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 */
42class analytics_model_testcase extends advanced_testcase {
43
44 public function setUp() {
45
1611308b
DM
46 $this->setAdminUser();
47
ff656bae
DM
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 }
53
54 $this->model = testable_model::create($target, $indicators);
55 $this->modelobj = $this->model->get_model_obj();
56 }
57
58 public function test_enable() {
59 $this->resetAfterTest(true);
60
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);
64
206d7aa9 65 $this->model->enable('\core\analytics\time_splitting\quarters');
ff656bae
DM
66 $this->assertEquals(1, $this->model->get_model_obj()->enabled);
67 $this->assertEquals(0, $this->model->get_model_obj()->trained);
206d7aa9 68 $this->assertEquals('\core\analytics\time_splitting\quarters', $this->model->get_model_obj()->timesplitting);
ff656bae
DM
69 }
70
71 public function test_create() {
72 $this->resetAfterTest(true);
73
206d7aa9 74 $target = \core_analytics\manager::get_target('\core\analytics\target\course_dropout');
ff656bae 75 $indicators = array(
206d7aa9
DM
76 \core_analytics\manager::get_indicator('\core\analytics\indicator\any_write_action'),
77 \core_analytics\manager::get_indicator('\core\analytics\indicator\read_actions')
ff656bae
DM
78 );
79 $model = \core_analytics\model::create($target, $indicators);
80 $this->assertInstanceOf('\core_analytics\model', $model);
81 }
82
99b84a26
DM
83 /**
84 * test_delete
85 */
86 public function test_delete() {
87 global $DB;
88
89 $this->resetAfterTest(true);
90 set_config('enabled_stores', 'logstore_standard', 'tool_log');
91
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));
96
97 $this->model->enable('\core\analytics\time_splitting\no_splitting');
98
99 $this->model->train();
100 $this->model->predict();
101
102 // Fake evaluation results record to check that it is actually deleted.
103 $this->add_fake_log();
104
abafbc84
DM
105 $modeloutputdir = $this->model->get_output_dir(array(), true);
106 $this->assertTrue(is_dir($modeloutputdir));
107
99b84a26
DM
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());
113
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'));
abafbc84 122 $this->assertFalse(is_dir($modeloutputdir));
99b84a26
DM
123
124 set_config('enabled_stores', '', 'tool_log');
125 get_log_manager(true);
126 }
127
128 /**
129 * test_clear
130 */
131 public function test_clear() {
132 global $DB;
133
134 $this->resetAfterTest(true);
135 set_config('enabled_stores', 'logstore_standard', 'tool_log');
136
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));
141
142 $this->model->enable('\core\analytics\time_splitting\no_splitting');
143
144 $this->model->train();
145 $this->model->predict();
146
147 // Fake evaluation results record to check that it is actually deleted.
148 $this->add_fake_log();
149
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());
155
abafbc84
DM
156 $modelversionoutputdir = $this->model->get_output_dir();
157 $this->assertTrue(is_dir($modelversionoutputdir));
158
325b3bdd
DM
159 // Update to an empty time splitting method to force model::clear execution.
160 $this->model->clear();
abafbc84
DM
161 $this->assertFalse(is_dir($modelversionoutputdir));
162
99b84a26
DM
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'));
171
c679d39c
DM
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)));
174
99b84a26
DM
175 set_config('enabled_stores', '', 'tool_log');
176 get_log_manager(true);
177 }
178
c679d39c
DM
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();
185
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();
190
191 // Static models are always considered trained.
192 $this->assertEquals(1, $DB->get_field('analytics_models', 'trained', array('id' => $modelobj->id)));
193
194 $model->clear();
195
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 }
199
ff656bae
DM
200 public function test_model_manager() {
201 $this->resetAfterTest(true);
202
203 $this->assertCount(3, $this->model->get_indicators());
204 $this->assertInstanceOf('\core_analytics\local\target\binary', $this->model->get_target());
205
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());
209
206d7aa9
DM
210 $this->model->enable('\core\analytics\time_splitting\quarters');
211 $this->assertInstanceOf('\core\analytics\analyser\site_courses', $this->model->get_analyser());
ff656bae
DM
212 }
213
214 public function test_output_dir() {
215 $this->resetAfterTest(true);
216
217 $dir = make_request_directory();
218 set_config('modeloutputdir', $dir, 'analytics');
219
220 $modeldir = $dir . DIRECTORY_SEPARATOR . $this->modelobj->id . DIRECTORY_SEPARATOR . $this->modelobj->version;
221 $this->assertEquals($modeldir, $this->model->get_output_dir());
abafbc84 222 $this->assertEquals($modeldir . DIRECTORY_SEPARATOR . 'testing', $this->model->get_output_dir(array('testing')));
ff656bae
DM
223 }
224
225 public function test_unique_id() {
226 global $DB;
227
228 $this->resetAfterTest(true);
229
230 $originaluniqueid = $this->model->get_unique_id();
231
232 // Same id across instances.
233 $this->model = new testable_model($this->modelobj);
234 $this->assertEquals($originaluniqueid, $this->model->get_unique_id());
235
236 // We will restore it later.
237 $originalversion = $this->modelobj->version;
238
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());
244
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);
250
251 $this->model->mark_as_trained();
252 $this->assertEquals($originaluniqueid, $this->model->get_unique_id());
253
ba211d56
DM
254 // Wait for the current timestamp to change.
255 $this->waitForSecond();
fabe98ac 256 $this->model->enable('\core\analytics\time_splitting\deciles');
ba211d56
DM
257 $this->assertNotEquals($originaluniqueid, $this->model->get_unique_id());
258 $uniqueid = $this->model->get_unique_id();
ff656bae 259
ba211d56
DM
260 // Wait for the current timestamp to change.
261 $this->waitForSecond();
206d7aa9 262 $this->model->enable('\core\analytics\time_splitting\quarters');
ff656bae 263 $this->assertNotEquals($originaluniqueid, $this->model->get_unique_id());
ba211d56 264 $this->assertNotEquals($uniqueid, $this->model->get_unique_id());
e709e544
DM
265 }
266
267 /**
268 * test_exists
269 *
270 * @return void
271 */
272 public function test_exists() {
273 $this->resetAfterTest(true);
274
e709e544
DM
275 $target = \core_analytics\manager::get_target('\core\analytics\target\no_teaching');
276 $this->assertTrue(\core_analytics\model::exists($target));
aa8af6fc
DM
277
278 foreach (\core_analytics\manager::get_all_models() as $model) {
279 $model->delete();
280 }
281
282 $this->assertFalse(\core_analytics\model::exists($target));
ff656bae 283 }
99b84a26 284
dd13fc22
DM
285 /**
286 * test_model_timelimit
287 *
288 * @return null
289 */
290 public function test_model_timelimit() {
291 global $DB;
292
293 $this->resetAfterTest(true);
294
295 set_config('modeltimelimit', 2, 'analytics');
296
297 $courses = array();
298 for ($i = 0; $i < 5; $i++) {
299 $course = $this->getDataGenerator()->create_course();
300 $analysable = new \core_analytics\course($course);
301 $courses[$analysable->get_id()] = $course;
302 }
303
304 $target = new test_target_course_level_shortname();
305 $analyser = new test_analyser(1, $target, [], [], []);
306
307 // Each analysable element takes 1.1 secs, so the max (and likely) number of analysable
308 // elements that will be processed is 2.
309 $analyser->get_analysable_data(false);
310 $params = array('modelid' => 1, 'action' => 'prediction');
311 $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params));
312
313 $analyser->get_analysable_data(false);
314 $this->assertLessThanOrEqual(4, $DB->count_records('analytics_used_analysables', $params));
315
316 // Check that analysable elements have been processed following the analyser order
317 // (course->sortorder here). We can not check this nicely after next get_analysable_data round
318 // because the first analysed element will be analysed again.
319 $analysedelems = $DB->get_records('analytics_used_analysables', $params, 'timeanalysed ASC');
320 // Just a default for the first checked element.
321 $last = (object)['sortorder' => PHP_INT_MAX];
322 foreach ($analysedelems as $analysed) {
323 if ($courses[$analysed->analysableid]->sortorder > $last->sortorder) {
324 $this->fail('Analysable elements have not been analysed sorted by course sortorder.');
325 }
326 $last = $courses[$analysed->analysableid];
327 }
328
329 $analyser->get_analysable_data(false);
330 $this->assertGreaterThanOrEqual(5, $DB->count_records('analytics_used_analysables', $params));
331
332 // New analysable elements are immediately pulled.
333 $this->getDataGenerator()->create_course();
334 $analyser->get_analysable_data(false);
335 $this->assertGreaterThanOrEqual(6, $DB->count_records('analytics_used_analysables', $params));
336
337 // Training and prediction data do not get mixed.
338 $analyser->get_analysable_data(true);
339 $params = array('modelid' => 1, 'action' => 'training');
340 $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params));
341 }
342
349c4412 343 /**
e4453adc 344 * Test model_config::get_class_component.
349c4412 345 */
e4453adc 346 public function test_model_config_get_class_component() {
349c4412
AA
347 $this->resetAfterTest(true);
348
e4453adc
DM
349 $this->assertEquals('core',
350 \core_analytics\model_config::get_class_component('\\core\\analytics\\indicator\\read_actions'));
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_course\\analytics\\indicator\\completion_enabled'));
355 $this->assertEquals('mod_forum',
356 \core_analytics\model_config::get_class_component('\\mod_forum\\analytics\\indicator\\cognitive_depth'));
357
358 $this->assertEquals('core', \core_analytics\model_config::get_class_component('\\core_class'));
349c4412
AA
359 }
360
361 /**
c70a7194 362 * Test that import_model import models' configurations.
349c4412 363 */
c70a7194 364 public function test_import_model_config() {
349c4412
AA
365 $this->resetAfterTest(true);
366
e4453adc 367 $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
c70a7194 368 $zipfilepath = $this->model->export_model('yeah-config.zip');
e4453adc
DM
369
370 $this->modelobj = $this->model->get_model_obj();
371
c70a7194 372 $importedmodelobj = \core_analytics\model::import_model($zipfilepath)->get_model_obj();
e4453adc 373
c70a7194
DM
374 $this->assertSame($this->modelobj->target, $importedmodelobj->target);
375 $this->assertSame($this->modelobj->indicators, $importedmodelobj->indicators);
376 $this->assertSame($this->modelobj->timesplitting, $importedmodelobj->timesplitting);
e4453adc 377
c70a7194
DM
378 $predictionsprocessor = $this->model->get_predictions_processor();
379 $this->assertSame('\\' . get_class($predictionsprocessor), $importedmodelobj->predictionsprocessor);
e4453adc
DM
380 }
381
382 /**
383 * Test can export configuration
384 */
385 public function test_can_export_configuration() {
386 $this->resetAfterTest(true);
387
388 // No time splitting method.
389 $this->assertFalse($this->model->can_export_configuration());
390
391 $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
392 $this->assertTrue($this->model->can_export_configuration());
393
394 $this->model->update(true, [], false);
395 $this->assertFalse($this->model->can_export_configuration());
396
397 $statictarget = new test_static_target_shortname();
398 $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
399 $model = \core_analytics\model::create($statictarget, $indicators, '\\core\\analytics\\time_splitting\\quarters');
400 $this->assertFalse($model->can_export_configuration());
401 }
402
403 /**
404 * Test export_config
405 */
406 public function test_export_config() {
407 $this->resetAfterTest(true);
408
409 $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
410
411 $modelconfig = new \core_analytics\model_config($this->model);
c70a7194
DM
412
413 $method = new ReflectionMethod('\\core_analytics\\model_config', 'export_model_data');
414 $method->setAccessible(true);
415
416 $modeldata = $method->invoke($modelconfig);
e4453adc
DM
417
418 $this->assertArrayHasKey('core', $modeldata->dependencies);
419 $this->assertInternalType('float', $modeldata->dependencies['core']);
420 $this->assertNotEmpty($modeldata->target);
421 $this->assertNotEmpty($modeldata->timesplitting);
422 $this->assertCount(3, $modeldata->indicators);
423
424 $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
425 $this->model->update(true, $indicators, false);
c70a7194
DM
426
427 $modeldata = $method->invoke($modelconfig);
e4453adc
DM
428
429 $this->assertCount(1, $modeldata->indicators);
349c4412
AA
430 }
431
99b84a26
DM
432 /**
433 * Generates a model log record.
434 */
435 private function add_fake_log() {
436 global $DB, $USER;
437
438 $log = new stdClass();
439 $log->modelid = $this->modelobj->id;
440 $log->version = $this->modelobj->version;
441 $log->target = $this->modelobj->target;
442 $log->indicators = $this->modelobj->indicators;
443 $log->score = 1;
444 $log->info = json_encode([]);
445 $log->dir = 'not important';
446 $log->timecreated = time();
447 $log->usermodified = $USER->id;
448 $DB->insert_record('analytics_models_log', $log);
449 }
ff656bae
DM
450}
451
413f19bc
DM
452/**
453 * Testable version to change methods' visibility.
454 *
455 * @package core_analytics
456 * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
457 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
458 */
ff656bae 459class testable_model extends \core_analytics\model {
413f19bc 460
413f19bc
DM
461 /**
462 * init_analyser
463 *
464 * @param array $options
465 * @return void
466 */
ff656bae 467 public function init_analyser($options = array()) {
413f19bc 468 parent::init_analyser($options);
ff656bae
DM
469 }
470}