Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.10% covered (danger)
37.10%
82 / 221
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Quiz
37.10% covered (danger)
37.10%
82 / 221
33.33% covered (danger)
33.33%
3 / 9
698.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
59.09% covered (warning)
59.09%
26 / 44
0.00% covered (danger)
0.00%
0 / 1
33.53
 resetQuizID
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getQuizId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSettingsTable
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
8
 parseQuiz
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 parseIncludes
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 parseInclude
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 parseQuestions
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 parseQuestion
30.00% covered (danger)
30.00%
24 / 80
0.00% covered (danger)
0.00%
0 / 1
81.23
1<?php
2
3namespace MediaWiki\Extension\Quiz;
4
5use MediaWiki\Extension\Quiz\Hooks\HookRunner;
6use MediaWiki\Html\TemplateParser;
7use MediaWiki\MediaWikiServices;
8use MediaWiki\Request\WebRequest;
9use MediaWiki\Title\Title;
10use Parser;
11use StringUtils;
12
13/**
14 * Processes quiz markup
15 */
16class Quiz {
17
18    /** @var int */
19    private static $sQuizId = 0;
20
21    /** @var Parser */
22    private $mParser;
23
24    /** @var WebRequest */
25    private $mRequest;
26
27    /** @var int */
28    private $mQuizId;
29
30    /** @var int */
31    private $mQuestionId;
32
33    /** @var int */
34    private $mShuffleDiv;
35
36    /** @var bool */
37    private $mBeingCorrected;
38
39    /** @var string */
40    private $mState;
41
42    /** @var int */
43    private $numberQuestions;
44
45    /** @var int */
46    private $mTotal;
47
48    /** @var int */
49    private $mScore;
50
51    /** @var int */
52    private $mAddedPoints;
53
54    /** @var int */
55    private $mCutoffPoints;
56
57    /** @var bool */
58    private $mIgnoringCoef;
59
60    /** @var bool */
61    private $mDisplaySimple;
62
63    /** @var bool */
64    private $shuffleAnswers;
65
66    /** @var bool */
67    private $mShuffle;
68
69    /** @var bool */
70    private $mCaseSensitive;
71
72    /** @var string */
73    private $mIncludePattern;
74
75    /**
76     * @param array $argv
77     * @param Parser $parser
78     */
79    public function __construct( $argv, Parser $parser ) {
80        global $wgRequest;
81        $this->mParser = $parser;
82        $this->mRequest = $wgRequest;
83        // Allot a unique identifier to the quiz.
84        $this->mQuizId = $this->getQuizId();
85        self::$sQuizId++;
86        // Reset the unique identifier of the questions.
87        $this->mQuestionId = 0;
88        // Reset the counter of div "shuffle" or "noshuffle" inside the quiz.
89        $this->mShuffleDiv = 0;
90        // Determine if this quiz is being corrected or not, according to the quizId
91        $this->mBeingCorrected = ( $wgRequest->getVal( 'quizId' ) == strval( $this->mQuizId ) );
92        // Initialize various parameters used for the score calculation
93        $this->mState = 'NA';
94        $this->numberQuestions = 0;
95        $this->mTotal = $this->mScore = 0;
96        $this->mAddedPoints = 1;
97        $this->mCutoffPoints = 0;
98        $this->mIgnoringCoef = false;
99        $this->mDisplaySimple = ( array_key_exists( 'display', $argv ) &&
100            $argv['display'] == 'simple' );
101        $this->shuffleAnswers = ( array_key_exists( 'shuffleanswers', $argv ) &&
102            $argv['shuffleanswers'] == 'true' );
103
104        if ( $this->mBeingCorrected ) {
105            $lAddedPoints = str_replace( ',', '.',
106                $this->mRequest->getVal( 'addedPoints', '' )
107            );
108            if ( is_numeric( $lAddedPoints ) ) {
109                $this->mAddedPoints = (int)$lAddedPoints;
110            }
111
112            $lCutoffPoints = str_replace( ',', '.',
113                $this->mRequest->getVal( 'cutoffPoints', '' )
114            );
115            if ( is_numeric( $lCutoffPoints ) ) {
116                $this->mCutoffPoints = (int)$lCutoffPoints;
117            }
118            if ( $this->mRequest->getVal( 'ignoringCoef' ) == 'on' ) {
119                $this->mIgnoringCoef = true;
120            }
121        }
122
123        if ( array_key_exists( 'points', $argv ) &&
124            ( !$this->mBeingCorrected || $this->mDisplaySimple ) &&
125            preg_match(
126                '`([\d\.]*)/?([\d\.]*)(!)?`', str_replace( ',', '.', $argv['points'] ), $matches
127            )
128        ) {
129            if ( is_numeric( $matches[1] ) ) {
130                $this->mAddedPoints = (int)$matches[1];
131            }
132            if ( is_numeric( $matches[2] ) ) {
133                $this->mCutoffPoints = (int)$matches[2];
134            }
135            if ( array_key_exists( 3, $matches ) ) {
136                $this->mIgnoringCoef = true;
137            }
138        }
139        $this->mShuffle = !( array_key_exists( 'shuffle', $argv ) && $argv['shuffle'] == 'none' );
140        $this->mCaseSensitive = !( array_key_exists( 'case', $argv ) && $argv['case'] == '(i)' );
141
142        // Patterns used in several places
143        $this->mIncludePattern = '`^\{\{:?(.*)\}\}[ \t]*`m';
144    }
145
146    public static function resetQuizID() {
147        self::$sQuizId = 0;
148    }
149
150    /**
151     * @return int Quiz Id
152     */
153    public function getQuizId() {
154        return self::$sQuizId;
155    }
156
157    /**
158     * Get HTML from template using TemplateParser
159     *
160     * @param TemplateParser $templateParser
161     * @return string
162     */
163    public function getSettingsTable( $templateParser ) {
164        $checked = $this->mIgnoringCoef ? 'checked="checked"' : '';
165        $settingsTable = $templateParser->processTemplate(
166            'Setting',
167            [
168                'isSettingFirstRow' => ( !$this->mDisplaySimple || $this->mBeingCorrected ||
169                    $this->mState === 'error' ),
170                'isSettingOtherRow' => ( !$this->mDisplaySimple || $this->mBeingCorrected ),
171                'notSimple' => !$this->mDisplaySimple,
172                /** @phan-suppress-next-line PhanPluginDuplicateExpressionBinaryOp FIXME */
173                'corrected' => ( $this->mBeingCorrected && $this->mBeingCorrected ),
174                'shuffle' => $this->mShuffle,
175                'shuffleOrError' => ( $this->mShuffle && $this->numberQuestions > 1 ) ||
176                    $this->mState === 'error',
177                'error' => $this->mState === 'error',
178                'wfMessage' => [
179                    'quiz_added' => wfMessage( 'quiz_addedPoints', $this->mAddedPoints )->text(),
180                    'quiz_cutoff' => wfMessage( 'quiz_cutoffPoints', $this->mCutoffPoints )->text(),
181                    'quiz_ignoreCoef' => wfMessage( 'quiz_ignoreCoef' )->text(),
182                    'quiz_legend_correct' => wfMessage( 'quiz_legend_correct' )->text(),
183                    'quiz_legend_incorrect' => wfMessage( 'quiz_legend_incorrect' )->text(),
184                    'quiz_legend_unanswered' => wfMessage( 'quiz_legend_unanswered' )->text(),
185                    'quiz_legend_error' => wfMessage( 'quiz_legend_error' )->text(),
186                    'quiz_shuffle' => wfMessage( 'quiz_shuffle' )->text()
187                ],
188                'mAddedPoints' => $this->mAddedPoints,
189                'mCutoffPoints' => $this->mCutoffPoints,
190                'checked' => $checked,
191                'shuffleDisplay' => $this->numberQuestions > 1
192            ]
193        );
194        return $settingsTable;
195    }
196
197    /**
198     * Convert the input text to an HTML output.
199     *
200     * @param string $input text between <quiz> and </quiz> tags, in quiz syntax.
201     * @return string
202     */
203    public function parseQuiz( $input ) {
204        // Ouput the style and the script to the header once for all.
205        if ( $this->mQuizId == 0 ) {
206            $this->mParser->getOutput()->addModules( [ 'ext.quiz' ] );
207            $this->mParser->getOutput()->addModuleStyles( [ 'ext.quiz.styles' ] );
208        }
209
210        // Process the input
211        $input = $this->parseQuestions( $this->parseIncludes( $input ) );
212
213        // Generates the output.
214        $templateParser = new TemplateParser( __DIR__ . '/../templates' );
215        // Determine the content of the settings table.
216        $settingsTable = $this->getSettingsTable( $templateParser );
217
218        $quiz_score = wfMessage( 'quiz_score' )->rawParams(
219            '<span class="score">' . $this->mScore . '</span>',
220            '<span class="total">' . $this->mTotal . '</span>' )->escaped();
221
222        return $templateParser->processTemplate(
223            'Quiz',
224            [
225                'quiz' => [
226                    'id' => $this->mQuizId,
227                    'beingCorrected' => $this->mBeingCorrected,
228                    'questions' => $input
229                ],
230                'settingsTable' => $settingsTable,
231                'wfMessage' => [
232                    'quiz_correction' => wfMessage( 'quiz_correction' )->escaped(),
233                    'quiz_reset' => wfMessage( 'quiz_reset' )->escaped(),
234                    'quiz_score' => $quiz_score
235                ]
236            ]
237        );
238    }
239
240    /**
241     * Replace inclusions from other quizzes.
242     *
243     * @param string $input text between <quiz> and </quiz> tags, in quiz syntax.
244     * @return string
245     */
246    private function parseIncludes( $input ) {
247        return preg_replace_callback(
248            $this->mIncludePattern,
249            [ $this, 'parseInclude' ],
250            $input
251        );
252    }
253
254    /**
255     * Include text between <quiz> and <quiz> from another page to this quiz.
256     *
257     * @param array $matches elements matching $includePattern
258     *                             $matches[1] is the page title.
259     * @return mixed|string
260     */
261    private function parseInclude( $matches ) {
262        $title = Title::makeTitleSafe( NS_MAIN, $matches[1] );
263        if ( $title === null ) {
264            // Not a valid title for this include; replace w/ empty string.
265            return '';
266        }
267        $text = $this->mParser->fetchTemplateAndTitle( $title )[0];
268        $output = '';
269        if ( preg_match( '`<quiz[^>]*>(.*?)</quiz>`sU', $text, $unparsedQuiz ) ) {
270            // Remove inclusions from included quiz.
271            $output = preg_replace(
272                $this->mIncludePattern,
273                '',
274                StringUtils::escapeRegexReplacement( $unparsedQuiz[1] )
275            );
276            $output .= "\n";
277        }
278        return $output;
279    }
280
281    /**
282     * Replace questions from quiz syntax to HTML.
283     *
284     * @param string $input a question in quiz syntax.
285     * @return string
286     */
287    private function parseQuestions( $input ) {
288        $splitPattern = '`(^|\n[ \t]*)\n\{`';
289        $unparsedQuestions = preg_split(
290            $splitPattern,
291            $input,
292            -1,
293            PREG_SPLIT_NO_EMPTY
294        );
295
296        $output = '';
297        $questionPattern = '`(.*?[^|\}])\}[ \t]*(\n(.*)|$)`s';
298        $this->numberQuestions = count( $unparsedQuestions );
299        $numDisplay = $this->numberQuestions > 1;
300        foreach ( $unparsedQuestions as $unparsedQuestion ) {
301            // If this "unparsedQuestion" is not a full question,
302            // we put the text into a buffer to add it at the beginning of the next question.
303            if ( !empty( $buffer ) ) {
304                $unparsedQuestion = $buffer . "\n\n" . '{' . $unparsedQuestion;
305            }
306
307            if ( preg_match( $questionPattern, $unparsedQuestion, $matches ) ) {
308                $buffer = '';
309                $output .= $this->parseQuestion( $matches, $numDisplay );
310            } else {
311                $buffer = $unparsedQuestion;
312            }
313        }
314
315        // Close unclosed "shuffle" or "noshuffle" tags.
316        while ( $this->mShuffleDiv > 0 ) {
317            $output .= '</div>';
318            $this->mShuffleDiv--;
319        }
320        return $output;
321    }
322
323    /**
324     * Convert a question from quiz syntax to HTML
325     *
326     * @param array $matches elements matching $questionPattern
327     *                         $matches[1] is the question header.
328     *                         $matches[3] is the question object.
329     * @param bool $numDisplay specifies whether to display question number.
330     * @return string
331     */
332    public function parseQuestion( $matches, $numDisplay ) {
333        $question = new Question(
334            $this->mBeingCorrected,
335            $this->mCaseSensitive,
336            $this->mQuestionId,
337            $this->shuffleAnswers,
338            $this->mParser
339        );
340        ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
341            ->onQuizQuestionCreated( $this, $question );
342
343        // gets the question text
344        $questionText = $question->parseHeader( $matches[1] );
345
346        /*
347            What is this block of code?
348            The only place X !X and /X are spoken about is here
349            https://en.wikiversity.org/wiki/Help:Quiz
350            "A few exotic features are not yet covered,
351            such as shuffle control using {X} {!X} {/X} tags."
352            These were added in commit fb53a3b0 back in 2007,
353            without any explanation and/or documentation. The commit message is actually unrelated.
354        */
355        if ( !array_key_exists( 3, $matches ) || trim( $matches[3] ) == '' ) {
356            switch ( $matches[1] ) {
357                case 'X':
358                    $this->mShuffleDiv++;
359                    return '<div class="shuffle">' . "\n";
360                case '!X':
361                    $this->mShuffleDiv++;
362                    return '<div class="noshuffle">' . "\n";
363                case '/X':
364                    // Prevent closing of other tags.
365                    if ( $this->mShuffleDiv == 0 ) {
366                        return '';
367                    } else {
368                        $this->mShuffleDiv--;
369                        return '</div>' . "\n";
370                    }
371                default:
372                    return '<div class="quizText">' . $questionText . '<br /></div>' . "\n";
373            }
374        }
375
376        $templateParser = new TemplateParser( __DIR__ . '/../templates' );
377
378        $this->mQuestionId++;
379
380        // This will generate the answers HTML code
381        $answers = call_user_func(
382            // Calling singleChoiceParseObject, multipleChoiceParseObject and textFieldParseObject
383            [ $question, $question->mType . 'ParseObject' ],
384            $matches[3]
385        );
386
387        // Set default table title and style
388
389        $tableTitle = "";
390
391        $lState = $question->getState(); // correct, incorrect or unanswered?
392
393        if ( $lState != '' ) {
394            // if the question is of type=simple
395            if ( $this->mIgnoringCoef ) {
396                $question->mCoef = 1;
397            }
398            switch ( $lState ) {
399                case 'correct':
400                    $this->mTotal += $this->mAddedPoints * $question->mCoef;
401                    $this->mScore += $this->mAddedPoints * $question->mCoef;
402
403                    $tableTitle = wfMessage(
404                        'quiz_points',
405                        wfMessage( 'quiz_legend_correct' )->text(),
406                        $this->mAddedPoints * $question->mCoef
407                    )->escaped();
408                    break;
409
410                case 'incorrect':
411                    $this->mTotal += $this->mAddedPoints * $question->mCoef;
412                    $this->mScore -= $this->mCutoffPoints * $question->mCoef;
413
414                    $tableTitle = wfMessage(
415                        'quiz_points',
416                        wfMessage( 'quiz_legend_incorrect' )->text(),
417                        -$this->mCutoffPoints * $question->mCoef
418                    )->escaped();
419                    break;
420
421                case 'NA':
422                    $this->mTotal += $this->mAddedPoints * $question->mCoef;
423
424                    $tableTitle = wfMessage(
425                        'quiz_points',
426                        wfMessage( 'quiz_legend_unanswered' )->text(),
427                        0
428                    )->escaped();
429                    break;
430
431                case 'error':
432                    $this->mState = 'error';
433                    break;
434            }
435        }
436
437        $stateObject = [
438            'state' => $lState,
439            'tableTitle' => $tableTitle
440        ];
441
442        return $templateParser->processTemplate(
443            'Question',
444            [
445                'question' => [
446                    'id' => $this->mQuestionId,
447                    'numdis' => $numDisplay,
448                    'text' => $questionText,
449                    'answers' => $answers
450                ],
451                'state' => $stateObject
452            ]
453        );
454    }
455}