weekly back-to-dev release 5.0dev
[moodle.git] / mod / quiz / report / statistics / tests / stats_from_steps_walkthrough_test.php
blob6f8e966e376a5268d3321c19b32532236890acde
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.
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/>.
17 namespace quiz_statistics;
19 use question_attempt;
20 use question_bank;
21 use question_finder;
22 use quiz_statistics_report;
24 defined('MOODLE_INTERNAL') || die();
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');
31 /**
32 * Quiz attempt walk through using data from csv file.
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
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.
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
48 class stats_from_steps_walkthrough_test extends \mod_quiz\attempt_walkthrough_from_csv_test {
50 /**
51 * @var quiz_statistics_report object to do stats calculations.
53 protected $report;
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";
60 /**
61 * @var string[] names of the files which contain the test data.
63 protected $files = ['questions', 'steps', 'results', 'qstats', 'responsecounts'];
65 /**
66 * Create a quiz add questions to it, walk through quiz attempts and then check results.
68 * @param array $csvdata data read from csv file "questionsXX.csv", "stepsXX.csv" and "resultsXX.csv".
69 * @dataProvider get_data_for_walkthrough
71 public function test_walkthrough_from_csv($quizsettings, $csvdata): void {
73 $this->create_quiz_simulate_attempts_and_check_results($quizsettings, $csvdata);
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);
87 /**
88 * Check actual question stats are the same as that found in csv file.
90 * @param $qstats array data from csv file.
91 * @param $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition Calculated stats.
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);
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');
116 * Check that the stat is as expected within a reasonable tolerance.
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
125 protected function assert_stat_equals($expected, $questionstats, $slot, $subqname, $variant, $statname) {
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};
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;
150 $delta = abs($expected) * $precision;
151 $this->assertEqualsWithDelta((float)$expected, $actual, $delta, $message);
152 } else {
153 $this->assertEquals($expected, $actual, $message);
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'];
165 list($subpartid, $responseclassid) = $this->get_response_subpart_and_class_id($question,
166 $subpart,
167 $expected['modelresponse']);
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('', '');
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);
186 $i++;
188 if (isset($expected['totalcount'])) {
189 $this->assertEquals($expected['totalcount'], $actualresponsecount->totalcount,
190 "Total count on ".$partofanalysis);
192 return;
195 throw new \coding_exception("Expected response '{$expected['actualresponse']}' not found.");
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.");
205 $subpartid = $possibleresponsesubpartids[$subpart - 1];
207 if ($modelresponse == '[NO RESPONSE]') {
208 return [$subpartid, null];
210 } else if ($modelresponse == '[NO MATCH]') {
211 return [$subpartid, 0];
214 $modelresponses = [];
215 foreach ($possibleresponses[$subpartid] as $responseclassid => $subpartpossibleresponse) {
216 $modelresponses[$responseclassid] = $subpartpossibleresponse->responseclass;
218 $this->assertContains($modelresponse, $modelresponses);
219 $responseclassid = array_search($modelresponse, $modelresponses);
220 return [$subpartid, $responseclassid];
224 * @param $responsecounts
225 * @param $qubaids
226 * @param $questions
227 * @param $whichtries
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;
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);
243 $this->assert_response_count_equals($question, $qubaids, $expected, $whichtries);
248 * @param $questions
249 * @param $questionstats
250 * @param $whichtries
251 * @param $qubaids
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]];
262 foreach ($questions as $slot => $question) {
263 if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
264 continue;
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);
276 $totalspervariantno = [];
277 foreach ($variantsnos as $variantno) {
279 $subpartids = $analysis->get_subpart_ids($variantno);
280 foreach ($subpartids as $subpartid) {
281 if (!isset($totalspervariantno[$subpartid])) {
282 $totalspervariantno[$subpartid] = [];
284 $totalspervariantno[$subpartid][$variantno] = 0;
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;
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}.");
321 foreach ($expectedvariantcounts as $slot => $expectedvariantcount) {
322 foreach ($expectedvariantcount as $variantno => $s) {
323 $this->assertEquals($s, $questionstats->for_slot($slot, $variantno)->s);
329 * @param $quizstats
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
346 foreach ($quizstatsexpected as $statname => $statvalue) {
347 $this->assertEqualsWithDelta($statvalue, $quizstats->$statname, abs($statvalue) * 1.5e-5, $quizstats->$statname);
352 * Check the question stats and the response counts used in the statistics report. If the appropriate files exist in fixtures/.
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.
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);
371 $qubaids = quiz_statistics_qubaids_condition($this->quiz->id, $groupstudentsjoins, $whichattempts);
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));
379 $qcalc = new \core_question\statistics\questions\calculator($questions);
380 $this->assertTimeCurrent($qcalc->get_last_calculated_time($qubaids));
382 if (isset($csvdata['responsecounts'])) {
383 $this->check_response_counts($csvdata['responsecounts'], $qubaids, $questions, $whichtries);
385 if (isset($csvdata['qstats'])) {
386 $this->check_question_stats($csvdata['qstats'], $questionstats);
387 return [$questions, $quizstats, $questionstats, $qubaids];
389 return [$questions, $quizstats, $questionstats, $qubaids];