Merge branch 'MDL-41760-master-v8' of https://github.com/jamiepratt/moodle
[moodle.git] / mod / quiz / report / statistics / tests / stats_from_steps_walkthrough_test.php
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/>.
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  */
35 defined('MOODLE_INTERNAL') || die();
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');
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 {
54     /**
55      * @var quiz_statistics_report object to do stats calculations.
56      */
57     protected $report;
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     }
64     protected $files = array('questions', 'steps', 'results', 'qstats', 'responsecounts');
66     /**
67      * Create a quiz add questions to it, walk through quiz attempts and then check results.
68      *
69      * @param PHPUnit_Extensions_Database_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) {
75         // CSV data files for these tests were generated using :
76         // https://github.com/jamiepratt/moodle-quiz-tools/tree/master/responsegenerator
78         $this->resetAfterTest(true);
79         question_bank::get_qtype('random')->clear_caches_before_testing();
81         $this->create_quiz($quizsettings, $csvdata['questions']);
83         $attemptids = $this->walkthrough_attempts($csvdata['steps']);
85         if (isset($csvdata['results'])) {
86             $this->check_attempts_results($csvdata['results'], $attemptids);
87         }
89         $this->report = new quiz_statistics_report();
90         $whichattempts = QUIZ_GRADEAVERAGE; // All attempts.
91         $whichtries = question_attempt::ALL_TRIES;
92         $groupstudents = array();
93         $questions = $this->report->load_and_initialise_questions_for_calculations($this->quiz);
94         list($quizstats, $questionstats) = $this->report->get_all_stats_and_analysis($this->quiz,
95                                                                                      $whichattempts,
96                                                                                      $whichtries,
97                                                                                      $groupstudents,
98                                                                                      $questions);
100         $qubaids = quiz_statistics_qubaids_condition($this->quiz->id, $groupstudents, $whichattempts);
102         // We will create some quiz and question stat calculator instances and some response analyser instances, just in order
103         // to check the last analysed time then returned.
104         $quizcalc = new \quiz_statistics\calculator();
105         // Should not be a delay of more than one second between the calculation of stats above and here.
106         $this->assertTimeCurrent($quizcalc->get_last_calculated_time($qubaids));
108         $qcalc = new \core_question\statistics\questions\calculator($questions);
109         $this->assertTimeCurrent($qcalc->get_last_calculated_time($qubaids));
111         if (isset($csvdata['responsecounts'])) {
112             $this->check_response_counts($csvdata['responsecounts'], $qubaids, $questions, $whichtries);
113         }
114         if (isset($csvdata['qstats'])) {
115             $this->check_question_stats($csvdata['qstats'], $questionstats);
116         }
117         if ($quizsettings['testnumber'] === '00') {
118             $this->check_variants_count_for_quiz_00($questions, $questionstats, $whichtries, $qubaids);
119             $this->check_quiz_stats_for_quiz_00($quizstats);
120         }
121     }
123     /**
124      * Check actual question stats are the same as that found in csv file.
125      *
126      * @param $qstats         PHPUnit_Extensions_Database_DataSet_ITable data from csv file.
127      * @param $questionstats  \core_question\statistics\questions\all_calculated_for_qubaid_condition Calculated stats.
128      */
129     protected function check_question_stats($qstats, $questionstats) {
130         for ($rowno = 0; $rowno < $qstats->getRowCount(); $rowno++) {
131             $slotqstats = $qstats->getRow($rowno);
132             foreach ($slotqstats as $statname => $slotqstat) {
133                 if (!in_array($statname, array('slot', 'subqname'))  && $slotqstat !== '') {
134                     $this->assert_stat_equals($slotqstat,
135                                               $questionstats,
136                                               $slotqstats['slot'],
137                                               $slotqstats['subqname'],
138                                               $slotqstats['variant'],
139                                               $statname);
140                 }
141             }
142             // Check that sub-question boolean field is correctly set.
143             $this->assert_stat_equals(!empty($slotqstats['subqname']),
144                                       $questionstats,
145                                       $slotqstats['slot'],
146                                       $slotqstats['subqname'],
147                                       $slotqstats['variant'],
148                                       'subquestion');
149         }
150     }
152     /**
153      * Check that the stat is as expected within a reasonable tolerance.
154      *
155      * @param float|string|bool $expected expected value of stat.
156      * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats
157      * @param int $slot
158      * @param string $subqname if empty string then not an item stat.
159      * @param int|string $variant if empty string then not a variantstat.
160      * @param string $statname
161      */
162     protected function assert_stat_equals($expected, $questionstats, $slot, $subqname, $variant, $statname) {
164         if ($variant === '' && $subqname === '') {
165             $actual = $questionstats->for_slot($slot)->{$statname};
166         } else if ($subqname !== '') {
167             $actual = $questionstats->for_subq($this->randqids[$slot][$subqname])->{$statname};
168         } else {
169             $actual = $questionstats->for_slot($slot, $variant)->{$statname};
170         }
171         $message = "$statname for slot $slot";
172         if ($expected === '**NULL**') {
173             $this->assertEquals(null, $actual, $message);
174         } else if (is_bool($expected)) {
175             $this->assertEquals($expected, $actual, $message);
176         } else if (is_numeric($expected)) {
177             switch ($statname) {
178                 case 'covariance' :
179                 case 'discriminationindex' :
180                 case 'discriminativeefficiency' :
181                 case 'effectiveweight' :
182                     $precision = 1e-5;
183                     break;
184                 default :
185                     $precision = 1e-6;
186             }
187             $delta = abs($expected) * $precision;
188             $this->assertEquals((float)$expected, $actual, $message, $delta);
189         } else {
190             $this->assertEquals($expected, $actual, $message);
191         }
192     }
194     protected function assert_response_count_equals($question, $qubaids, $expected, $whichtries) {
195         $responesstats = new \core_question\statistics\responses\analyser($question);
196         $analysis = $responesstats->load_cached($qubaids, $whichtries);
197         if (!isset($expected['subpart'])) {
198             $subpart = 1;
199         } else {
200             $subpart = $expected['subpart'];
201         }
202         list($subpartid, $responseclassid) = $this->get_response_subpart_and_class_id($question,
203                                                                                       $subpart,
204                                                                                       $expected['modelresponse']);
206         $subpartanalysis = $analysis->get_analysis_for_subpart($expected['variant'], $subpartid);
207         $responseclassanalysis = $subpartanalysis->get_response_class($responseclassid);
208         $actualresponsecounts = $responseclassanalysis->data_for_question_response_table('', '');
210         foreach ($actualresponsecounts as $actualresponsecount) {
211             if ($actualresponsecount->response == $expected['actualresponse'] ||
212                             count($actualresponsecounts) == 1) {
213                 $i = 1;
214                 $partofanalysis = " slot {$expected['slot']}, rand q '{$expected['randq']}', variant {$expected['variant']}, ".
215                                     "for expected model response {$expected['modelresponse']}, ".
216                                     "actual response {$expected['actualresponse']}";
217                 while (isset($expected['count'.$i])) {
218                     if ($expected['count'.$i] != 0) {
219                         $this->assertTrue(isset($actualresponsecount->trycount[$i]),
220                             "There is no count at all for try $i on ".$partofanalysis);
221                         $this->assertEquals($expected['count'.$i], $actualresponsecount->trycount[$i],
222                                             "Count for try $i on ".$partofanalysis);
223                     }
224                     $i++;
225                 }
226                 if (isset($expected['totalcount'])) {
227                     $this->assertEquals($expected['totalcount'], $actualresponsecount->totalcount,
228                                         "Total count on ".$partofanalysis);
229                 }
230                 return;
231             }
232         }
233         throw new coding_exception("Expected response '{$expected['actualresponse']}' not found.");
234     }
236     protected function get_response_subpart_and_class_id($question, $subpart, $modelresponse) {
237         $qtypeobj = question_bank::get_qtype($question->qtype, false);
238         $possibleresponses = $qtypeobj->get_possible_responses($question);
239         $possibleresponsesubpartids = array_keys($possibleresponses);
240         if (!isset($possibleresponsesubpartids[$subpart - 1])) {
241             throw new coding_exception("Subpart '{$subpart}' not found.");
242         }
243         $subpartid = $possibleresponsesubpartids[$subpart - 1];
245         if ($modelresponse == '[NO RESPONSE]') {
246             return array($subpartid, null);
248         } else if ($modelresponse == '[NO MATCH]') {
249             return array($subpartid, 0);
250         }
252         $modelresponses = array();
253         foreach ($possibleresponses[$subpartid] as $responseclassid => $subpartpossibleresponse) {
254             $modelresponses[$responseclassid] = $subpartpossibleresponse->responseclass;
255         }
256         $this->assertContains($modelresponse, $modelresponses);
257         $responseclassid = array_search($modelresponse, $modelresponses);
258         return array($subpartid, $responseclassid);
259     }
261     /**
262      * @param $responsecounts
263      * @param $qubaids
264      * @param $questions
265      * @param $whichtries
266      */
267     protected function check_response_counts($responsecounts, $qubaids, $questions, $whichtries) {
268         for ($rowno = 0; $rowno < $responsecounts->getRowCount(); $rowno++) {
269             $expected = $responsecounts->getRow($rowno);
270             $defaultsforexpected = array('randq' => '', 'variant' => '1', 'subpart' => '1');
271             foreach ($defaultsforexpected as $key => $expecteddefault) {
272                 if (!isset($expected[$key])) {
273                     $expected[$key] = $expecteddefault;
274                 }
275             }
276             if ($expected['randq'] == '') {
277                 $question = $questions[$expected['slot']];
278             } else {
279                 $qid = $this->randqids[$expected['slot']][$expected['randq']];
280                 $question = question_finder::get_instance()->load_question_data($qid);
281             }
282             $this->assert_response_count_equals($question, $qubaids, $expected, $whichtries);
283         }
284     }
286     /**
287      * @param $questions
288      * @param $questionstats
289      * @param $whichtries
290      * @param $qubaids
291      */
292     protected function check_variants_count_for_quiz_00($questions, $questionstats, $whichtries, $qubaids) {
293         $expectedvariantcounts = array(2 => array(1  => 6,
294                                                   4  => 4,
295                                                   5  => 3,
296                                                   6  => 4,
297                                                   7  => 2,
298                                                   8  => 5,
299                                                   10 => 1));
301         foreach ($questions as $slot => $question) {
302             if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
303                 continue;
304             }
305             $responesstats = new \core_question\statistics\responses\analyser($question);
306             $this->assertTimeCurrent($responesstats->get_last_analysed_time($qubaids, $whichtries));
307             $analysis = $responesstats->load_cached($qubaids, $whichtries);
308             $variantsnos = $analysis->get_variant_nos();
309             if (isset($expectedvariantcounts[$slot])) {
310                 // Compare contents, ignore ordering of array, using canonicalize parameter of assertEquals.
311                 $this->assertEquals(array_keys($expectedvariantcounts[$slot]), $variantsnos, '', 0, 10, true);
312             } else {
313                 $this->assertEquals(array(1), $variantsnos);
314             }
315             $totalspervariantno = array();
316             foreach ($variantsnos as $variantno) {
318                 $subpartids = $analysis->get_subpart_ids($variantno);
319                 foreach ($subpartids as $subpartid) {
320                     if (!isset($totalspervariantno[$subpartid])) {
321                         $totalspervariantno[$subpartid] = array();
322                     }
323                     $totalspervariantno[$subpartid][$variantno] = 0;
325                     $subpartanalysis = $analysis->get_analysis_for_subpart($variantno, $subpartid);
326                     $classids = $subpartanalysis->get_response_class_ids();
327                     foreach ($classids as $classid) {
328                         $classanalysis = $subpartanalysis->get_response_class($classid);
329                         $actualresponsecounts = $classanalysis->data_for_question_response_table('', '');
330                         foreach ($actualresponsecounts as $actualresponsecount) {
331                             $totalspervariantno[$subpartid][$variantno] += $actualresponsecount->totalcount;
332                         }
333                     }
334                 }
335             }
336             // Count all counted responses for each part of question and confirm that counted responses, for most question types
337             // are the number of attempts at the question for each question part.
338             if ($slot != 5) {
339                 // Slot 5 holds a multi-choice multiple question.
340                 // Multi-choice multiple is slightly strange. Actual answer counts given for each sub part do not add up to the
341                 // total attempt count.
342                 // This is because each option is counted as a sub part and each option can be off or on in each attempt. Off is
343                 // not counted in response analysis for this question type.
344                 foreach ($totalspervariantno as $totalpervariantno) {
345                     if (isset($expectedvariantcounts[$slot])) {
346                         // If we know how many attempts there are at each variant we can check
347                         // that we have counted the correct amount of responses for each variant.
348                         $this->assertEquals($expectedvariantcounts[$slot],
349                                             $totalpervariantno,
350                                             "Totals responses do not add up in response analysis for slot {$slot}.",
351                                             0,
352                                             10,
353                                             true);
354                     } else {
355                         $this->assertEquals(25,
356                                             array_sum($totalpervariantno),
357                                             "Totals responses do not add up in response analysis for slot {$slot}.");
358                     }
359                 }
360             }
361         }
363         foreach ($expectedvariantcounts as $slot => $expectedvariantcount) {
364             foreach ($expectedvariantcount as $variantno => $s) {
365                 $this->assertEquals($s, $questionstats->for_slot($slot, $variantno)->s);
366             }
367         }
368     }
370     /**
371      * @param $quizstats
372      */
373     protected function check_quiz_stats_for_quiz_00($quizstats) {
374         $quizstatsexpected = array(
375             'median'             => 4.5,
376             'firstattemptsavg'   => 4.617333332,
377             'allattemptsavg'     => 4.617333332,
378             'firstattemptscount' => 25,
379             'allattemptscount'   => 25,
380             'standarddeviation'  => 0.8117265554,
381             'skewness'           => -0.092502502,
382             'kurtosis'           => -0.7073968557,
383             'cic'                => -87.2230935542,
384             'errorratio'         => 136.8294900795,
385             'standarderror'      => 1.1106813066
386         );
388         foreach ($quizstatsexpected as $statname => $statvalue) {
389             $this->assertEquals($statvalue, $quizstats->$statname, $quizstats->$statname, abs($statvalue) * 1.5e-5);
390         }
391     }