Proyectos de Subversion Moodle

Rev

Rev 11 | | Comparar con el anterior | Ultima modificación | Ver Log |

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