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