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