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