See Release Notes
Long Term Support Release
Differences Between: [Versions 39 and 310] [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 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 * Quiz attempt walk through using data from csv file. 19 * 20 * The quiz stats below and the question stats found in qstats00.csv were calculated independently in a spreadsheet which is 21 * available in open document or excel format here : 22 * https://github.com/jamiepratt/moodle-quiz-tools/tree/master/statsspreadsheet 23 * 24 * Similarly the question variant's stats in qstats00.csv are calculated in stats_for_variant_1.xls and stats_for_variant_8.xls 25 * The calculations in the spreadsheets are the same as for the other question stats but applied just to the attempts where the 26 * variants appeared. 27 * 28 * @package quiz_statistics 29 * @category phpunit 30 * @copyright 2013 The Open University 31 * @author Jamie Pratt <me@jamiep.org> 32 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 33 */ 34 35 defined('MOODLE_INTERNAL') || die(); 36 37 global $CFG; 38 require_once($CFG->dirroot . '/mod/quiz/tests/attempt_walkthrough_from_csv_test.php'); 39 require_once($CFG->dirroot . '/mod/quiz/report/default.php'); 40 require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); 41 require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); 42 43 /** 44 * Quiz attempt walk through using data from csv file. 45 * 46 * @package quiz_statistics 47 * @category phpunit 48 * @copyright 2013 The Open University 49 * @author Jamie Pratt <me@jamiep.org> 50 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 51 */ 52 class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkthrough_from_csv_testcase { 53 54 /** 55 * @var quiz_statistics_report object to do stats calculations. 56 */ 57 protected $report; 58 59 protected function get_full_path_of_csv_file($setname, $test) { 60 // Overridden here so that __DIR__ points to the path of this file. 61 return __DIR__."/fixtures/{$setname}{$test}.csv"; 62 } 63 64 protected $files = array('questions', 'steps', 'results', 'qstats', 'responsecounts'); 65 66 /** 67 * Create a quiz add questions to it, walk through quiz attempts and then check results. 68 * 69 * @param PHPUnit\DbUnit\DataSet\ITable[] of data read from csv file "questionsXX.csv", 70 * "stepsXX.csv" and "resultsXX.csv". 71 * @dataProvider get_data_for_walkthrough 72 */ 73 public function test_walkthrough_from_csv($quizsettings, $csvdata) { 74 75 $this->create_quiz_simulate_attempts_and_check_results($quizsettings, $csvdata); 76 77 $whichattempts = QUIZ_GRADEAVERAGE; // All attempts. 78 $whichtries = question_attempt::ALL_TRIES; 79 $groupstudentsjoins = new \core\dml\sql_join(); 80 list($questions, $quizstats, $questionstats, $qubaids) = 81 $this->check_stats_calculations_and_response_analysis($csvdata, 82 $whichattempts, $whichtries, $groupstudentsjoins); 83 if ($quizsettings['testnumber'] === '00') { 84 $this->check_variants_count_for_quiz_00($questions, $questionstats, $whichtries, $qubaids); 85 $this->check_quiz_stats_for_quiz_00($quizstats); 86 } 87 } 88 89 /** 90 * Check actual question stats are the same as that found in csv file. 91 * 92 * @param $qstats PHPUnit\DbUnit\DataSet\ITable data from csv file. 93 * @param $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition Calculated stats. 94 */ 95 protected function check_question_stats($qstats, $questionstats) { 96 for ($rowno = 0; $rowno < $qstats->getRowCount(); $rowno++) { 97 $slotqstats = $qstats->getRow($rowno); 98 foreach ($slotqstats as $statname => $slotqstat) { 99 if (!in_array($statname, array('slot', 'subqname')) && $slotqstat !== '') { 100 $this->assert_stat_equals($slotqstat, 101 $questionstats, 102 $slotqstats['slot'], 103 $slotqstats['subqname'], 104 $slotqstats['variant'], 105 $statname); 106 } 107 } 108 // Check that sub-question boolean field is correctly set. 109 $this->assert_stat_equals(!empty($slotqstats['subqname']), 110 $questionstats, 111 $slotqstats['slot'], 112 $slotqstats['subqname'], 113 $slotqstats['variant'], 114 'subquestion'); 115 } 116 } 117 118 /** 119 * Check that the stat is as expected within a reasonable tolerance. 120 * 121 * @param float|string|bool $expected expected value of stat. 122 * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats 123 * @param int $slot 124 * @param string $subqname if empty string then not an item stat. 125 * @param int|string $variant if empty string then not a variantstat. 126 * @param string $statname 127 */ 128 protected function assert_stat_equals($expected, $questionstats, $slot, $subqname, $variant, $statname) { 129 130 if ($variant === '' && $subqname === '') { 131 $actual = $questionstats->for_slot($slot)->{$statname}; 132 } else if ($subqname !== '') { 133 $actual = $questionstats->for_subq($this->randqids[$slot][$subqname])->{$statname}; 134 } else { 135 $actual = $questionstats->for_slot($slot, $variant)->{$statname}; 136 } 137 $message = "$statname for slot $slot"; 138 if ($expected === '**NULL**') { 139 $this->assertEquals(null, $actual, $message); 140 } else if (is_bool($expected)) { 141 $this->assertEquals($expected, $actual, $message); 142 } else if (is_numeric($expected)) { 143 switch ($statname) { 144 case 'covariance' : 145 case 'discriminationindex' : 146 case 'discriminativeefficiency' : 147 case 'effectiveweight' : 148 $precision = 1e-5; 149 break; 150 default : 151 $precision = 1e-6; 152 } 153 $delta = abs($expected) * $precision; 154 $this->assertEquals((float)$expected, $actual, $message, $delta); 155 } else { 156 $this->assertEquals($expected, $actual, $message); 157 } 158 } 159 160 protected function assert_response_count_equals($question, $qubaids, $expected, $whichtries) { 161 $responesstats = new \core_question\statistics\responses\analyser($question); 162 $analysis = $responesstats->load_cached($qubaids, $whichtries); 163 if (!isset($expected['subpart'])) { 164 $subpart = 1; 165 } else { 166 $subpart = $expected['subpart']; 167 } 168 list($subpartid, $responseclassid) = $this->get_response_subpart_and_class_id($question, 169 $subpart, 170 $expected['modelresponse']); 171 172 $subpartanalysis = $analysis->get_analysis_for_subpart($expected['variant'], $subpartid); 173 $responseclassanalysis = $subpartanalysis->get_response_class($responseclassid); 174 $actualresponsecounts = $responseclassanalysis->data_for_question_response_table('', ''); 175 176 foreach ($actualresponsecounts as $actualresponsecount) { 177 if ($actualresponsecount->response == $expected['actualresponse'] || count($actualresponsecounts) == 1) { 178 $i = 1; 179 $partofanalysis = " slot {$expected['slot']}, rand q '{$expected['randq']}', variant {$expected['variant']}, ". 180 "for expected model response {$expected['modelresponse']}, ". 181 "actual response {$expected['actualresponse']}"; 182 while (isset($expected['count'.$i])) { 183 if ($expected['count'.$i] != 0) { 184 $this->assertTrue(isset($actualresponsecount->trycount[$i]), 185 "There is no count at all for try $i on ".$partofanalysis); 186 $this->assertEquals($expected['count'.$i], $actualresponsecount->trycount[$i], 187 "Count for try $i on ".$partofanalysis); 188 } 189 $i++; 190 } 191 if (isset($expected['totalcount'])) { 192 $this->assertEquals($expected['totalcount'], $actualresponsecount->totalcount, 193 "Total count on ".$partofanalysis); 194 } 195 return; 196 } 197 } 198 throw new coding_exception("Expected response '{$expected['actualresponse']}' not found."); 199 } 200 201 protected function get_response_subpart_and_class_id($question, $subpart, $modelresponse) { 202 $qtypeobj = question_bank::get_qtype($question->qtype, false); 203 $possibleresponses = $qtypeobj->get_possible_responses($question); 204 $possibleresponsesubpartids = array_keys($possibleresponses); 205 if (!isset($possibleresponsesubpartids[$subpart - 1])) { 206 throw new coding_exception("Subpart '{$subpart}' not found."); 207 } 208 $subpartid = $possibleresponsesubpartids[$subpart - 1]; 209 210 if ($modelresponse == '[NO RESPONSE]') { 211 return array($subpartid, null); 212 213 } else if ($modelresponse == '[NO MATCH]') { 214 return array($subpartid, 0); 215 } 216 217 $modelresponses = array(); 218 foreach ($possibleresponses[$subpartid] as $responseclassid => $subpartpossibleresponse) { 219 $modelresponses[$responseclassid] = $subpartpossibleresponse->responseclass; 220 } 221 $this->assertContains($modelresponse, $modelresponses); 222 $responseclassid = array_search($modelresponse, $modelresponses); 223 return array($subpartid, $responseclassid); 224 } 225 226 /** 227 * @param $responsecounts 228 * @param $qubaids 229 * @param $questions 230 * @param $whichtries 231 */ 232 protected function check_response_counts($responsecounts, $qubaids, $questions, $whichtries) { 233 for ($rowno = 0; $rowno < $responsecounts->getRowCount(); $rowno++) { 234 $expected = $responsecounts->getRow($rowno); 235 $defaultsforexpected = array('randq' => '', 'variant' => '1', 'subpart' => '1'); 236 foreach ($defaultsforexpected as $key => $expecteddefault) { 237 if (!isset($expected[$key])) { 238 $expected[$key] = $expecteddefault; 239 } 240 } 241 if ($expected['randq'] == '') { 242 $question = $questions[$expected['slot']]; 243 } else { 244 $qid = $this->randqids[$expected['slot']][$expected['randq']]; 245 $question = question_finder::get_instance()->load_question_data($qid); 246 } 247 $this->assert_response_count_equals($question, $qubaids, $expected, $whichtries); 248 } 249 } 250 251 /** 252 * @param $questions 253 * @param $questionstats 254 * @param $whichtries 255 * @param $qubaids 256 */ 257 protected function check_variants_count_for_quiz_00($questions, $questionstats, $whichtries, $qubaids) { 258 $expectedvariantcounts = array(2 => array(1 => 6, 259 4 => 4, 260 5 => 3, 261 6 => 4, 262 7 => 2, 263 8 => 5, 264 10 => 1)); 265 266 foreach ($questions as $slot => $question) { 267 if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) { 268 continue; 269 } 270 $responesstats = new \core_question\statistics\responses\analyser($question); 271 $this->assertTimeCurrent($responesstats->get_last_analysed_time($qubaids, $whichtries)); 272 $analysis = $responesstats->load_cached($qubaids, $whichtries); 273 $variantsnos = $analysis->get_variant_nos(); 274 if (isset($expectedvariantcounts[$slot])) { 275 // Compare contents, ignore ordering of array, using canonicalize parameter of assertEquals. 276 $this->assertEquals(array_keys($expectedvariantcounts[$slot]), $variantsnos, '', 0, 10, true); 277 } else { 278 $this->assertEquals(array(1), $variantsnos); 279 } 280 $totalspervariantno = array(); 281 foreach ($variantsnos as $variantno) { 282 283 $subpartids = $analysis->get_subpart_ids($variantno); 284 foreach ($subpartids as $subpartid) { 285 if (!isset($totalspervariantno[$subpartid])) { 286 $totalspervariantno[$subpartid] = array(); 287 } 288 $totalspervariantno[$subpartid][$variantno] = 0; 289 290 $subpartanalysis = $analysis->get_analysis_for_subpart($variantno, $subpartid); 291 $classids = $subpartanalysis->get_response_class_ids(); 292 foreach ($classids as $classid) { 293 $classanalysis = $subpartanalysis->get_response_class($classid); 294 $actualresponsecounts = $classanalysis->data_for_question_response_table('', ''); 295 foreach ($actualresponsecounts as $actualresponsecount) { 296 $totalspervariantno[$subpartid][$variantno] += $actualresponsecount->totalcount; 297 } 298 } 299 } 300 } 301 // Count all counted responses for each part of question and confirm that counted responses, for most question types 302 // are the number of attempts at the question for each question part. 303 if ($slot != 5) { 304 // Slot 5 holds a multi-choice multiple question. 305 // Multi-choice multiple is slightly strange. Actual answer counts given for each sub part do not add up to the 306 // total attempt count. 307 // This is because each option is counted as a sub part and each option can be off or on in each attempt. Off is 308 // not counted in response analysis for this question type. 309 foreach ($totalspervariantno as $totalpervariantno) { 310 if (isset($expectedvariantcounts[$slot])) { 311 // If we know how many attempts there are at each variant we can check 312 // that we have counted the correct amount of responses for each variant. 313 $this->assertEquals($expectedvariantcounts[$slot], 314 $totalpervariantno, 315 "Totals responses do not add up in response analysis for slot {$slot}.", 316 0, 317 10, 318 true); 319 } else { 320 $this->assertEquals(25, 321 array_sum($totalpervariantno), 322 "Totals responses do not add up in response analysis for slot {$slot}."); 323 } 324 } 325 } 326 } 327 328 foreach ($expectedvariantcounts as $slot => $expectedvariantcount) { 329 foreach ($expectedvariantcount as $variantno => $s) { 330 $this->assertEquals($s, $questionstats->for_slot($slot, $variantno)->s); 331 } 332 } 333 } 334 335 /** 336 * @param $quizstats 337 */ 338 protected function check_quiz_stats_for_quiz_00($quizstats) { 339 $quizstatsexpected = array( 340 'median' => 4.5, 341 'firstattemptsavg' => 4.617333332, 342 'allattemptsavg' => 4.617333332, 343 'firstattemptscount' => 25, 344 'allattemptscount' => 25, 345 'standarddeviation' => 0.8117265554, 346 'skewness' => -0.092502502, 347 'kurtosis' => -0.7073968557, 348 'cic' => -87.2230935542, 349 'errorratio' => 136.8294900795, 350 'standarderror' => 1.1106813066 351 ); 352 353 foreach ($quizstatsexpected as $statname => $statvalue) { 354 $this->assertEquals($statvalue, $quizstats->$statname, $quizstats->$statname, abs($statvalue) * 1.5e-5); 355 } 356 } 357 358 /** 359 * Check the question stats and the response counts used in the statistics report. If the appropriate files exist in fixtures/. 360 * 361 * @param PHPUnit\DbUnit\DataSet\ITable[] $csvdata Data loaded from csv files for this test. 362 * @param string $whichattempts 363 * @param string $whichtries 364 * @param \core\dml\sql_join $groupstudentsjoins 365 * @return array with contents 0 => $questions, 1 => $quizstats, 2=> $questionstats, 3=> $qubaids Might be needed for further 366 * testing. 367 */ 368 protected function check_stats_calculations_and_response_analysis($csvdata, $whichattempts, $whichtries, 369 \core\dml\sql_join $groupstudentsjoins) { 370 $this->report = new quiz_statistics_report(); 371 $questions = $this->report->load_and_initialise_questions_for_calculations($this->quiz); 372 list($quizstats, $questionstats) = $this->report->get_all_stats_and_analysis($this->quiz, 373 $whichattempts, 374 $whichtries, 375 $groupstudentsjoins, 376 $questions); 377 378 $qubaids = quiz_statistics_qubaids_condition($this->quiz->id, $groupstudentsjoins, $whichattempts); 379 380 // We will create some quiz and question stat calculator instances and some response analyser instances, just in order 381 // to check the last analysed time then returned. 382 $quizcalc = new \quiz_statistics\calculator(); 383 // Should not be a delay of more than one second between the calculation of stats above and here. 384 $this->assertTimeCurrent($quizcalc->get_last_calculated_time($qubaids)); 385 386 $qcalc = new \core_question\statistics\questions\calculator($questions); 387 $this->assertTimeCurrent($qcalc->get_last_calculated_time($qubaids)); 388 389 if (isset($csvdata['responsecounts'])) { 390 $this->check_response_counts($csvdata['responsecounts'], $qubaids, $questions, $whichtries); 391 } 392 if (isset($csvdata['qstats'])) { 393 $this->check_question_stats($csvdata['qstats'], $questionstats); 394 return array($questions, $quizstats, $questionstats, $qubaids); 395 } 396 return array($questions, $quizstats, $questionstats, $qubaids); 397 } 398 399 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body