Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403]
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 * 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 */ 24 25 namespace core_analytics; 26 27 defined('MOODLE_INTERNAL') || die(); 28 29 require_once (__DIR__ . '/fixtures/test_indicator_max.php'); 30 require_once (__DIR__ . '/fixtures/test_indicator_min.php'); 31 require_once (__DIR__ . '/fixtures/test_indicator_fullname.php'); 32 require_once (__DIR__ . '/fixtures/test_target_shortname.php'); 33 require_once (__DIR__ . '/fixtures/test_static_target_shortname.php'); 34 require_once (__DIR__ . '/fixtures/test_target_course_level_shortname.php'); 35 require_once (__DIR__ . '/fixtures/test_analysis.php'); 36 37 /** 38 * Unit tests for the model. 39 * 40 * @package core_analytics 41 * @copyright 2017 David MonllaĆ³ {@link http://www.davidmonllao.com} 42 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 43 */ 44 class model_test extends \advanced_testcase { 45 46 /** @var model Store Model. */ 47 protected $model; 48 49 /** @var \stdClass Store model object. */ 50 protected $modelobj; 51 52 public function setUp(): void { 53 54 $this->setAdminUser(); 55 56 $target = \core_analytics\manager::get_target('test_target_shortname'); 57 $indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname'); 58 foreach ($indicators as $key => $indicator) { 59 $indicators[$key] = \core_analytics\manager::get_indicator($indicator); 60 } 61 62 $this->model = testable_model::create($target, $indicators); 63 $this->modelobj = $this->model->get_model_obj(); 64 } 65 66 public function test_enable() { 67 $this->resetAfterTest(true); 68 69 $this->assertEquals(0, $this->model->get_model_obj()->enabled); 70 $this->assertEquals(0, $this->model->get_model_obj()->trained); 71 $this->assertEquals('', $this->model->get_model_obj()->timesplitting); 72 73 $this->model->enable('\core\analytics\time_splitting\quarters'); 74 $this->assertEquals(1, $this->model->get_model_obj()->enabled); 75 $this->assertEquals(0, $this->model->get_model_obj()->trained); 76 $this->assertEquals('\core\analytics\time_splitting\quarters', $this->model->get_model_obj()->timesplitting); 77 } 78 79 public function test_create() { 80 $this->resetAfterTest(true); 81 82 $target = \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout'); 83 $indicators = array( 84 \core_analytics\manager::get_indicator('\core\analytics\indicator\any_write_action'), 85 \core_analytics\manager::get_indicator('\core\analytics\indicator\read_actions') 86 ); 87 $model = \core_analytics\model::create($target, $indicators); 88 $this->assertInstanceOf('\core_analytics\model', $model); 89 } 90 91 /** 92 * test_delete 93 */ 94 public function test_delete() { 95 global $DB; 96 97 $this->resetAfterTest(true); 98 set_config('enabled_stores', 'logstore_standard', 'tool_log'); 99 100 $coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0)); 101 $coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0)); 102 $coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1)); 103 $coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1)); 104 105 $this->model->enable('\core\analytics\time_splitting\single_range'); 106 107 $this->model->train(); 108 $this->model->predict(); 109 110 // Fake evaluation results record to check that it is actually deleted. 111 $this->add_fake_log(); 112 113 $modeloutputdir = $this->model->get_output_dir(array(), true); 114 $this->assertTrue(is_dir($modeloutputdir)); 115 116 // Generate a prediction action to confirm that it is deleted when there is an important update. 117 $predictions = $DB->get_records('analytics_predictions'); 118 $prediction = reset($predictions); 119 $prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used')); 120 $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target()); 121 122 $this->model->delete(); 123 $this->assertEmpty($DB->count_records('analytics_models', array('id' => $this->modelobj->id))); 124 $this->assertEmpty($DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id))); 125 $this->assertEmpty($DB->count_records('analytics_predictions')); 126 $this->assertEmpty($DB->count_records('analytics_prediction_actions')); 127 $this->assertEmpty($DB->count_records('analytics_train_samples')); 128 $this->assertEmpty($DB->count_records('analytics_predict_samples')); 129 $this->assertEmpty($DB->count_records('analytics_used_files')); 130 $this->assertFalse(is_dir($modeloutputdir)); 131 132 set_config('enabled_stores', '', 'tool_log'); 133 get_log_manager(true); 134 } 135 136 /** 137 * test_clear 138 */ 139 public function test_clear() { 140 global $DB; 141 142 $this->resetAfterTest(true); 143 set_config('enabled_stores', 'logstore_standard', 'tool_log'); 144 145 $coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0)); 146 $coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0)); 147 $coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1)); 148 $coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1)); 149 150 $this->model->enable('\core\analytics\time_splitting\single_range'); 151 152 $this->model->train(); 153 $this->model->predict(); 154 155 // Fake evaluation results record to check that it is actually deleted. 156 $this->add_fake_log(); 157 158 // Generate a prediction action to confirm that it is deleted when there is an important update. 159 $predictions = $DB->get_records('analytics_predictions'); 160 $prediction = reset($predictions); 161 $prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used')); 162 $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target()); 163 164 $modelversionoutputdir = $this->model->get_output_dir(); 165 $this->assertTrue(is_dir($modelversionoutputdir)); 166 167 // Update to an empty time splitting method to force model::clear execution. 168 $this->model->clear(); 169 $this->assertFalse(is_dir($modelversionoutputdir)); 170 171 // Check that most of the stuff got deleted. 172 $this->assertEquals(1, $DB->count_records('analytics_models', array('id' => $this->modelobj->id))); 173 $this->assertEquals(1, $DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id))); 174 $this->assertEmpty($DB->count_records('analytics_predictions')); 175 $this->assertEmpty($DB->count_records('analytics_prediction_actions')); 176 $this->assertEmpty($DB->count_records('analytics_train_samples')); 177 $this->assertEmpty($DB->count_records('analytics_predict_samples')); 178 $this->assertEmpty($DB->count_records('analytics_used_files')); 179 180 // Check that the model is marked as not trained after clearing (as it is not a static one). 181 $this->assertEquals(0, $DB->get_field('analytics_models', 'trained', array('id' => $this->modelobj->id))); 182 183 set_config('enabled_stores', '', 'tool_log'); 184 get_log_manager(true); 185 } 186 187 /** 188 * Test behaviour of {\core_analytics\model::clear()} for static models. 189 */ 190 public function test_clear_static() { 191 global $DB; 192 $this->resetAfterTest(); 193 194 $statictarget = new \test_static_target_shortname(); 195 $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max'); 196 $model = \core_analytics\model::create($statictarget, $indicators, '\core\analytics\time_splitting\quarters'); 197 $modelobj = $model->get_model_obj(); 198 199 // Static models are always considered trained. 200 $this->assertEquals(1, $DB->get_field('analytics_models', 'trained', array('id' => $modelobj->id))); 201 202 $model->clear(); 203 204 // Check that the model is still marked as trained even after clearing. 205 $this->assertEquals(1, $DB->get_field('analytics_models', 'trained', array('id' => $modelobj->id))); 206 } 207 208 public function test_model_manager() { 209 $this->resetAfterTest(true); 210 211 $this->assertCount(3, $this->model->get_indicators()); 212 $this->assertInstanceOf('\core_analytics\local\target\binary', $this->model->get_target()); 213 214 // Using evaluation as the model is not yet enabled. 215 $this->model->init_analyser(array('evaluation' => true)); 216 $this->assertInstanceOf('\core_analytics\local\analyser\base', $this->model->get_analyser()); 217 218 $this->model->enable('\core\analytics\time_splitting\quarters'); 219 $this->assertInstanceOf('\core\analytics\analyser\site_courses', $this->model->get_analyser()); 220 } 221 222 public function test_output_dir() { 223 $this->resetAfterTest(true); 224 225 $dir = make_request_directory(); 226 set_config('modeloutputdir', $dir, 'analytics'); 227 228 $modeldir = $dir . DIRECTORY_SEPARATOR . $this->modelobj->id . DIRECTORY_SEPARATOR . $this->modelobj->version; 229 $this->assertEquals($modeldir, $this->model->get_output_dir()); 230 $this->assertEquals($modeldir . DIRECTORY_SEPARATOR . 'testing', $this->model->get_output_dir(array('testing'))); 231 } 232 233 public function test_unique_id() { 234 global $DB; 235 236 $this->resetAfterTest(true); 237 238 $originaluniqueid = $this->model->get_unique_id(); 239 240 // Same id across instances. 241 $this->model = new testable_model($this->modelobj); 242 $this->assertEquals($originaluniqueid, $this->model->get_unique_id()); 243 244 // We will restore it later. 245 $originalversion = $this->modelobj->version; 246 247 // Generates a different id if timemodified changes. 248 $this->modelobj->version = $this->modelobj->version + 10; 249 $DB->update_record('analytics_models', $this->modelobj); 250 $this->model = new testable_model($this->modelobj); 251 $this->assertNotEquals($originaluniqueid, $this->model->get_unique_id()); 252 253 // Restore original timemodified to continue testing. 254 $this->modelobj->version = $originalversion; 255 $DB->update_record('analytics_models', $this->modelobj); 256 // Same when updating through an action that changes the model. 257 $this->model = new testable_model($this->modelobj); 258 259 $this->model->mark_as_trained(); 260 $this->assertEquals($originaluniqueid, $this->model->get_unique_id()); 261 262 // Wait for the current timestamp to change. 263 $this->waitForSecond(); 264 $this->model->enable('\core\analytics\time_splitting\deciles'); 265 $this->assertNotEquals($originaluniqueid, $this->model->get_unique_id()); 266 $uniqueid = $this->model->get_unique_id(); 267 268 // Wait for the current timestamp to change. 269 $this->waitForSecond(); 270 $this->model->enable('\core\analytics\time_splitting\quarters'); 271 $this->assertNotEquals($originaluniqueid, $this->model->get_unique_id()); 272 $this->assertNotEquals($uniqueid, $this->model->get_unique_id()); 273 } 274 275 /** 276 * test_exists 277 * 278 * @return void 279 */ 280 public function test_exists() { 281 $this->resetAfterTest(true); 282 283 $target = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching'); 284 $this->assertTrue(\core_analytics\model::exists($target)); 285 286 foreach (\core_analytics\manager::get_all_models() as $model) { 287 $model->delete(); 288 } 289 290 $this->assertFalse(\core_analytics\model::exists($target)); 291 } 292 293 /** 294 * test_model_timelimit 295 * 296 * @return null 297 */ 298 public function test_model_timelimit() { 299 global $DB; 300 301 $this->resetAfterTest(true); 302 303 set_config('modeltimelimit', 2, 'analytics'); 304 305 $courses = array(); 306 for ($i = 0; $i < 5; $i++) { 307 $course = $this->getDataGenerator()->create_course(); 308 $analysable = new \core_analytics\course($course); 309 $courses[$analysable->get_id()] = $course; 310 } 311 312 $target = new \test_target_course_level_shortname(); 313 $analyser = new \core\analytics\analyser\courses(1, $target, [], [], []); 314 315 $result = new \core_analytics\local\analysis\result_array(1, false, []); 316 $analysis = new \test_analysis($analyser, false, $result); 317 318 // Each analysable element takes 0.5 secs minimum (test_analysis), so the max (and likely) number of analysable 319 // elements that will be processed is 2. 320 $analysis->run(); 321 $params = array('modelid' => 1, 'action' => 'prediction'); 322 $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params)); 323 324 $analysis->run(); 325 $this->assertLessThanOrEqual(4, $DB->count_records('analytics_used_analysables', $params)); 326 327 // Check that analysable elements have been processed following the analyser order 328 // (course->sortorder here). We can not check this nicely after next get_unlabelled_data round 329 // because the first analysed element will be analysed again. 330 $analysedelems = $DB->get_records('analytics_used_analysables', $params, 'timeanalysed ASC'); 331 // Just a default for the first checked element. 332 $last = (object)['sortorder' => PHP_INT_MAX]; 333 foreach ($analysedelems as $analysed) { 334 if ($courses[$analysed->analysableid]->sortorder > $last->sortorder) { 335 $this->fail('Analysable elements have not been analysed sorted by course sortorder.'); 336 } 337 $last = $courses[$analysed->analysableid]; 338 } 339 340 // No time limit now to process the rest. 341 set_config('modeltimelimit', 1000, 'analytics'); 342 343 $analysis->run(); 344 $this->assertEquals(5, $DB->count_records('analytics_used_analysables', $params)); 345 346 // New analysable elements are immediately pulled. 347 $this->getDataGenerator()->create_course(); 348 $analysis->run(); 349 $this->assertEquals(6, $DB->count_records('analytics_used_analysables', $params)); 350 351 // Training and prediction data do not get mixed. 352 $result = new \core_analytics\local\analysis\result_array(1, false, []); 353 $analysis = new \test_analysis($analyser, false, $result); 354 $analysis->run(); 355 $params = array('modelid' => 1, 'action' => 'training'); 356 $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params)); 357 } 358 359 /** 360 * Test model_config::get_class_component. 361 */ 362 public function test_model_config_get_class_component() { 363 $this->resetAfterTest(true); 364 365 $this->assertEquals('core', 366 \core_analytics\model_config::get_class_component('\\core\\analytics\\indicator\\read_actions')); 367 $this->assertEquals('core', 368 \core_analytics\model_config::get_class_component('core\\analytics\\indicator\\read_actions')); 369 $this->assertEquals('core', 370 \core_analytics\model_config::get_class_component('\\core_course\\analytics\\indicator\\completion_enabled')); 371 $this->assertEquals('mod_forum', 372 \core_analytics\model_config::get_class_component('\\mod_forum\\analytics\\indicator\\cognitive_depth')); 373 374 $this->assertEquals('core', \core_analytics\model_config::get_class_component('\\core_class')); 375 } 376 377 /** 378 * Test that import_model import models' configurations. 379 */ 380 public function test_import_model_config() { 381 $this->resetAfterTest(true); 382 383 $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); 384 $zipfilepath = $this->model->export_model('yeah-config.zip'); 385 386 $this->modelobj = $this->model->get_model_obj(); 387 388 $importedmodelobj = \core_analytics\model::import_model($zipfilepath)->get_model_obj(); 389 390 $this->assertSame($this->modelobj->target, $importedmodelobj->target); 391 $this->assertSame($this->modelobj->indicators, $importedmodelobj->indicators); 392 $this->assertSame($this->modelobj->timesplitting, $importedmodelobj->timesplitting); 393 394 $predictionsprocessor = $this->model->get_predictions_processor(); 395 $this->assertSame('\\' . get_class($predictionsprocessor), $importedmodelobj->predictionsprocessor); 396 } 397 398 /** 399 * Test can export configuration 400 */ 401 public function test_can_export_configuration() { 402 $this->resetAfterTest(true); 403 404 // No time splitting method. 405 $this->assertFalse($this->model->can_export_configuration()); 406 407 $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); 408 $this->assertTrue($this->model->can_export_configuration()); 409 410 $this->model->update(true, [], false); 411 $this->assertFalse($this->model->can_export_configuration()); 412 413 $statictarget = new \test_static_target_shortname(); 414 $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max'); 415 $model = \core_analytics\model::create($statictarget, $indicators, '\\core\\analytics\\time_splitting\\quarters'); 416 $this->assertFalse($model->can_export_configuration()); 417 } 418 419 /** 420 * Test export_config 421 */ 422 public function test_export_config() { 423 $this->resetAfterTest(true); 424 425 $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); 426 427 $modelconfig = new \core_analytics\model_config($this->model); 428 429 $method = new \ReflectionMethod('\\core_analytics\\model_config', 'export_model_data'); 430 $method->setAccessible(true); 431 432 $modeldata = $method->invoke($modelconfig); 433 434 $this->assertArrayHasKey('core', $modeldata->dependencies); 435 $this->assertIsFloat($modeldata->dependencies['core']); 436 $this->assertNotEmpty($modeldata->target); 437 $this->assertNotEmpty($modeldata->timesplitting); 438 $this->assertCount(3, $modeldata->indicators); 439 440 $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max'); 441 $this->model->update(true, $indicators, false); 442 443 $modeldata = $method->invoke($modelconfig); 444 445 $this->assertCount(1, $modeldata->indicators); 446 } 447 448 /** 449 * Test the implementation of {@link \core_analytics\model::inplace_editable_name()}. 450 */ 451 public function test_inplace_editable_name() { 452 global $PAGE; 453 454 $this->resetAfterTest(); 455 456 $output = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL); 457 458 // Check as a user with permission to edit the name. 459 $this->setAdminUser(); 460 $ie = $this->model->inplace_editable_name(); 461 $this->assertInstanceOf(\core\output\inplace_editable::class, $ie); 462 $data = $ie->export_for_template($output); 463 $this->assertEquals('core_analytics', $data['component']); 464 $this->assertEquals('modelname', $data['itemtype']); 465 466 // Check as a user without permission to edit the name. 467 $this->setGuestUser(); 468 $ie = $this->model->inplace_editable_name(); 469 $this->assertInstanceOf(\core\output\inplace_editable::class, $ie); 470 $data = $ie->export_for_template($output); 471 $this->assertArrayHasKey('displayvalue', $data); 472 } 473 474 /** 475 * Test how the models present themselves in the UI and that they can be renamed. 476 */ 477 public function test_get_name_and_rename() { 478 global $PAGE; 479 480 $this->resetAfterTest(); 481 482 $output = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL); 483 484 // By default, the model exported for template uses its target's name in the name inplace editable element. 485 $this->assertEquals($this->model->get_name(), $this->model->get_target()->get_name()); 486 $data = $this->model->export($output); 487 $this->assertEquals($data->name['displayvalue'], $this->model->get_target()->get_name()); 488 $this->assertEquals($data->name['value'], ''); 489 490 // Rename the model. 491 $this->model->rename('NÄjakĆ½ pokusnĆ½ model'); 492 $this->assertEquals($this->model->get_name(), 'NÄjakĆ½ pokusnĆ½ model'); 493 $data = $this->model->export($output); 494 $this->assertEquals($data->name['displayvalue'], 'NÄjakĆ½ pokusnĆ½ model'); 495 $this->assertEquals($data->name['value'], 'NÄjakĆ½ pokusnĆ½ model'); 496 497 // Undo the renaming. 498 $this->model->rename(''); 499 $this->assertEquals($this->model->get_name(), $this->model->get_target()->get_name()); 500 $data = $this->model->export($output); 501 $this->assertEquals($data->name['displayvalue'], $this->model->get_target()->get_name()); 502 $this->assertEquals($data->name['value'], ''); 503 } 504 505 /** 506 * Tests model::get_potential_timesplittings() 507 */ 508 public function test_potential_timesplittings() { 509 $this->resetAfterTest(); 510 511 $this->assertArrayNotHasKey('\core\analytics\time_splitting\no_splitting', $this->model->get_potential_timesplittings()); 512 $this->assertArrayHasKey('\core\analytics\time_splitting\single_range', $this->model->get_potential_timesplittings()); 513 $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $this->model->get_potential_timesplittings()); 514 } 515 516 /** 517 * Tests model::get_samples() 518 * 519 * @return null 520 */ 521 public function test_get_samples() { 522 $this->resetAfterTest(); 523 524 if (!PHPUNIT_LONGTEST) { 525 $this->markTestSkipped('PHPUNIT_LONGTEST is not defined'); 526 } 527 528 // 10000 should be enough to make oracle and mssql fail, if we want pgsql to fail we need around 70000 529 // users, that is a few minutes just to create the users. 530 $nusers = 10000; 531 532 $userids = []; 533 for ($i = 0; $i < $nusers; $i++) { 534 $user = $this->getDataGenerator()->create_user(); 535 $userids[] = $user->id; 536 } 537 538 $upcomingactivities = null; 539 foreach (\core_analytics\manager::get_all_models() as $model) { 540 if (get_class($model->get_target()) === 'core_user\\analytics\\target\\upcoming_activities_due') { 541 $upcomingactivities = $model; 542 } 543 } 544 545 list($sampleids, $samplesdata) = $upcomingactivities->get_samples($userids); 546 $this->assertCount($nusers, $sampleids); 547 $this->assertCount($nusers, $samplesdata); 548 549 $subset = array_slice($userids, 0, 100); 550 list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); 551 $this->assertCount(100, $sampleids); 552 $this->assertCount(100, $samplesdata); 553 554 $subset = array_slice($userids, 0, 2); 555 list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); 556 $this->assertCount(2, $sampleids); 557 $this->assertCount(2, $samplesdata); 558 559 $subset = array_slice($userids, 0, 1); 560 list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); 561 $this->assertCount(1, $sampleids); 562 $this->assertCount(1, $samplesdata); 563 564 // Unexisting, so nothing returned, but still 2 arrays. 565 list($sampleids, $samplesdata) = $upcomingactivities->get_samples([1231231231231231]); 566 $this->assertEmpty($sampleids); 567 $this->assertEmpty($samplesdata); 568 569 } 570 571 /** 572 * Generates a model log record. 573 */ 574 private function add_fake_log() { 575 global $DB, $USER; 576 577 $log = new \stdClass(); 578 $log->modelid = $this->modelobj->id; 579 $log->version = $this->modelobj->version; 580 $log->target = $this->modelobj->target; 581 $log->indicators = $this->modelobj->indicators; 582 $log->score = 1; 583 $log->info = json_encode([]); 584 $log->dir = 'not important'; 585 $log->timecreated = time(); 586 $log->usermodified = $USER->id; 587 $DB->insert_record('analytics_models_log', $log); 588 } 589 } 590 591 /** 592 * Testable version to change methods' visibility. 593 * 594 * @package core_analytics 595 * @copyright 2017 David MonllaĆ³ {@link http://www.davidmonllao.com} 596 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 597 */ 598 class testable_model extends \core_analytics\model { 599 600 /** 601 * init_analyser 602 * 603 * @param array $options 604 * @return void 605 */ 606 public function init_analyser($options = array()) { 607 parent::init_analyser($options); 608 } 609 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body