Differences Between: [Versions 310 and 311] [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403] [Versions 39 and 310]
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 defined('MOODLE_INTERNAL') || die(); 26 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_analysis.php'); 34 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 { 43 44 public function setUp(): void { 45 46 $this->setAdminUser(); 47 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 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 } 70 71 public function test_create() { 72 $this->resetAfterTest(true); 73 74 $target = \core_analytics\manager::get_target('\core_course\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 } 82 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\single_range'); 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 105 $modeloutputdir = $this->model->get_output_dir(array(), true); 106 $this->assertTrue(is_dir($modeloutputdir)); 107 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')); 122 $this->assertFalse(is_dir($modeloutputdir)); 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\single_range'); 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 156 $modelversionoutputdir = $this->model->get_output_dir(); 157 $this->assertTrue(is_dir($modelversionoutputdir)); 158 159 // Update to an empty time splitting method to force model::clear execution. 160 $this->model->clear(); 161 $this->assertFalse(is_dir($modelversionoutputdir)); 162 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 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 175 set_config('enabled_stores', '', 'tool_log'); 176 get_log_manager(true); 177 } 178 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 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 210 $this->model->enable('\core\analytics\time_splitting\quarters'); 211 $this->assertInstanceOf('\core\analytics\analyser\site_courses', $this->model->get_analyser()); 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()); 222 $this->assertEquals($modeldir . DIRECTORY_SEPARATOR . 'testing', $this->model->get_output_dir(array('testing'))); 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 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(); 259 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 } 266 267 /** 268 * test_exists 269 * 270 * @return void 271 */ 272 public function test_exists() { 273 $this->resetAfterTest(true); 274 275 $target = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching'); 276 $this->assertTrue(\core_analytics\model::exists($target)); 277 278 foreach (\core_analytics\manager::get_all_models() as $model) { 279 $model->delete(); 280 } 281 282 $this->assertFalse(\core_analytics\model::exists($target)); 283 } 284 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 \core\analytics\analyser\courses(1, $target, [], [], []); 306 307 $result = new \core_analytics\local\analysis\result_array(1, false, []); 308 $analysis = new test_analysis($analyser, false, $result); 309 310 // Each analysable element takes 0.5 secs minimum (test_analysis), so the max (and likely) number of analysable 311 // elements that will be processed is 2. 312 $analysis->run(); 313 $params = array('modelid' => 1, 'action' => 'prediction'); 314 $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params)); 315 316 $analysis->run(); 317 $this->assertLessThanOrEqual(4, $DB->count_records('analytics_used_analysables', $params)); 318 319 // Check that analysable elements have been processed following the analyser order 320 // (course->sortorder here). We can not check this nicely after next get_unlabelled_data round 321 // because the first analysed element will be analysed again. 322 $analysedelems = $DB->get_records('analytics_used_analysables', $params, 'timeanalysed ASC'); 323 // Just a default for the first checked element. 324 $last = (object)['sortorder' => PHP_INT_MAX]; 325 foreach ($analysedelems as $analysed) { 326 if ($courses[$analysed->analysableid]->sortorder > $last->sortorder) { 327 $this->fail('Analysable elements have not been analysed sorted by course sortorder.'); 328 } 329 $last = $courses[$analysed->analysableid]; 330 } 331 332 // No time limit now to process the rest. 333 set_config('modeltimelimit', 1000, 'analytics'); 334 335 $analysis->run(); 336 $this->assertEquals(5, $DB->count_records('analytics_used_analysables', $params)); 337 338 // New analysable elements are immediately pulled. 339 $this->getDataGenerator()->create_course(); 340 $analysis->run(); 341 $this->assertEquals(6, $DB->count_records('analytics_used_analysables', $params)); 342 343 // Training and prediction data do not get mixed. 344 $result = new \core_analytics\local\analysis\result_array(1, false, []); 345 $analysis = new test_analysis($analyser, false, $result); 346 $analysis->run(); 347 $params = array('modelid' => 1, 'action' => 'training'); 348 $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params)); 349 } 350 351 /** 352 * Test model_config::get_class_component. 353 */ 354 public function test_model_config_get_class_component() { 355 $this->resetAfterTest(true); 356 357 $this->assertEquals('core', 358 \core_analytics\model_config::get_class_component('\\core\\analytics\\indicator\\read_actions')); 359 $this->assertEquals('core', 360 \core_analytics\model_config::get_class_component('core\\analytics\\indicator\\read_actions')); 361 $this->assertEquals('core', 362 \core_analytics\model_config::get_class_component('\\core_course\\analytics\\indicator\\completion_enabled')); 363 $this->assertEquals('mod_forum', 364 \core_analytics\model_config::get_class_component('\\mod_forum\\analytics\\indicator\\cognitive_depth')); 365 366 $this->assertEquals('core', \core_analytics\model_config::get_class_component('\\core_class')); 367 } 368 369 /** 370 * Test that import_model import models' configurations. 371 */ 372 public function test_import_model_config() { 373 $this->resetAfterTest(true); 374 375 $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); 376 $zipfilepath = $this->model->export_model('yeah-config.zip'); 377 378 $this->modelobj = $this->model->get_model_obj(); 379 380 $importedmodelobj = \core_analytics\model::import_model($zipfilepath)->get_model_obj(); 381 382 $this->assertSame($this->modelobj->target, $importedmodelobj->target); 383 $this->assertSame($this->modelobj->indicators, $importedmodelobj->indicators); 384 $this->assertSame($this->modelobj->timesplitting, $importedmodelobj->timesplitting); 385 386 $predictionsprocessor = $this->model->get_predictions_processor(); 387 $this->assertSame('\\' . get_class($predictionsprocessor), $importedmodelobj->predictionsprocessor); 388 } 389 390 /** 391 * Test can export configuration 392 */ 393 public function test_can_export_configuration() { 394 $this->resetAfterTest(true); 395 396 // No time splitting method. 397 $this->assertFalse($this->model->can_export_configuration()); 398 399 $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); 400 $this->assertTrue($this->model->can_export_configuration()); 401 402 $this->model->update(true, [], false); 403 $this->assertFalse($this->model->can_export_configuration()); 404 405 $statictarget = new test_static_target_shortname(); 406 $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max'); 407 $model = \core_analytics\model::create($statictarget, $indicators, '\\core\\analytics\\time_splitting\\quarters'); 408 $this->assertFalse($model->can_export_configuration()); 409 } 410 411 /** 412 * Test export_config 413 */ 414 public function test_export_config() { 415 $this->resetAfterTest(true); 416 417 $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); 418 419 $modelconfig = new \core_analytics\model_config($this->model); 420 421 $method = new ReflectionMethod('\\core_analytics\\model_config', 'export_model_data'); 422 $method->setAccessible(true); 423 424 $modeldata = $method->invoke($modelconfig); 425 426 $this->assertArrayHasKey('core', $modeldata->dependencies); 427 $this->assertIsFloat($modeldata->dependencies['core']); 428 $this->assertNotEmpty($modeldata->target); 429 $this->assertNotEmpty($modeldata->timesplitting); 430 $this->assertCount(3, $modeldata->indicators); 431 432 $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max'); 433 $this->model->update(true, $indicators, false); 434 435 $modeldata = $method->invoke($modelconfig); 436 437 $this->assertCount(1, $modeldata->indicators); 438 } 439 440 /** 441 * Test the implementation of {@link \core_analytics\model::inplace_editable_name()}. 442 */ 443 public function test_inplace_editable_name() { 444 global $PAGE; 445 446 $this->resetAfterTest(); 447 448 $output = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL); 449 450 // Check as a user with permission to edit the name. 451 $this->setAdminUser(); 452 $ie = $this->model->inplace_editable_name(); 453 $this->assertInstanceOf(\core\output\inplace_editable::class, $ie); 454 $data = $ie->export_for_template($output); 455 $this->assertEquals('core_analytics', $data['component']); 456 $this->assertEquals('modelname', $data['itemtype']); 457 458 // Check as a user without permission to edit the name. 459 $this->setGuestUser(); 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->assertArrayHasKey('displayvalue', $data); 464 } 465 466 /** 467 * Test how the models present themselves in the UI and that they can be renamed. 468 */ 469 public function test_get_name_and_rename() { 470 global $PAGE; 471 472 $this->resetAfterTest(); 473 474 $output = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL); 475 476 // By default, the model exported for template uses its target's name in the name inplace editable element. 477 $this->assertEquals($this->model->get_name(), $this->model->get_target()->get_name()); 478 $data = $this->model->export($output); 479 $this->assertEquals($data->name['displayvalue'], $this->model->get_target()->get_name()); 480 $this->assertEquals($data->name['value'], ''); 481 482 // Rename the model. 483 $this->model->rename('NÄjakĆ½ pokusnĆ½ model'); 484 $this->assertEquals($this->model->get_name(), 'NÄjakĆ½ pokusnĆ½ model'); 485 $data = $this->model->export($output); 486 $this->assertEquals($data->name['displayvalue'], 'NÄjakĆ½ pokusnĆ½ model'); 487 $this->assertEquals($data->name['value'], 'NÄjakĆ½ pokusnĆ½ model'); 488 489 // Undo the renaming. 490 $this->model->rename(''); 491 $this->assertEquals($this->model->get_name(), $this->model->get_target()->get_name()); 492 $data = $this->model->export($output); 493 $this->assertEquals($data->name['displayvalue'], $this->model->get_target()->get_name()); 494 $this->assertEquals($data->name['value'], ''); 495 } 496 497 /** 498 * Tests model::get_potential_timesplittings() 499 */ 500 public function test_potential_timesplittings() { 501 $this->resetAfterTest(); 502 503 $this->assertArrayNotHasKey('\core\analytics\time_splitting\no_splitting', $this->model->get_potential_timesplittings()); 504 $this->assertArrayHasKey('\core\analytics\time_splitting\single_range', $this->model->get_potential_timesplittings()); 505 $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $this->model->get_potential_timesplittings()); 506 } 507 508 /** 509 * Tests model::get_samples() 510 * 511 * @return null 512 */ 513 public function test_get_samples() { 514 $this->resetAfterTest(); 515 516 if (!PHPUNIT_LONGTEST) { 517 $this->markTestSkipped('PHPUNIT_LONGTEST is not defined'); 518 } 519 520 // 10000 should be enough to make oracle and mssql fail, if we want pgsql to fail we need around 70000 521 // users, that is a few minutes just to create the users. 522 $nusers = 10000; 523 524 $userids = []; 525 for ($i = 0; $i < $nusers; $i++) { 526 $user = $this->getDataGenerator()->create_user(); 527 $userids[] = $user->id; 528 } 529 530 $upcomingactivities = null; 531 foreach (\core_analytics\manager::get_all_models() as $model) { 532 if (get_class($model->get_target()) === 'core_user\\analytics\\target\\upcoming_activities_due') { 533 $upcomingactivities = $model; 534 } 535 } 536 537 list($sampleids, $samplesdata) = $upcomingactivities->get_samples($userids); 538 $this->assertCount($nusers, $sampleids); 539 $this->assertCount($nusers, $samplesdata); 540 541 $subset = array_slice($userids, 0, 100); 542 list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); 543 $this->assertCount(100, $sampleids); 544 $this->assertCount(100, $samplesdata); 545 546 $subset = array_slice($userids, 0, 2); 547 list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); 548 $this->assertCount(2, $sampleids); 549 $this->assertCount(2, $samplesdata); 550 551 $subset = array_slice($userids, 0, 1); 552 list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); 553 $this->assertCount(1, $sampleids); 554 $this->assertCount(1, $samplesdata); 555 556 // Unexisting, so nothing returned, but still 2 arrays. 557 list($sampleids, $samplesdata) = $upcomingactivities->get_samples([1231231231231231]); 558 $this->assertEmpty($sampleids); 559 $this->assertEmpty($samplesdata); 560 561 } 562 563 /** 564 * Generates a model log record. 565 */ 566 private function add_fake_log() { 567 global $DB, $USER; 568 569 $log = new stdClass(); 570 $log->modelid = $this->modelobj->id; 571 $log->version = $this->modelobj->version; 572 $log->target = $this->modelobj->target; 573 $log->indicators = $this->modelobj->indicators; 574 $log->score = 1; 575 $log->info = json_encode([]); 576 $log->dir = 'not important'; 577 $log->timecreated = time(); 578 $log->usermodified = $USER->id; 579 $DB->insert_record('analytics_models_log', $log); 580 } 581 } 582 583 /** 584 * Testable version to change methods' visibility. 585 * 586 * @package core_analytics 587 * @copyright 2017 David MonllaĆ³ {@link http://www.davidmonllao.com} 588 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 589 */ 590 class testable_model extends \core_analytics\model { 591 592 /** 593 * init_analyser 594 * 595 * @param array $options 596 * @return void 597 */ 598 public function init_analyser($options = array()) { 599 parent::init_analyser($options); 600 } 601 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body