Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
37.10% |
82 / 221 |
|
33.33% |
3 / 9 |
CRAP | |
0.00% |
0 / 1 |
Quiz | |
37.10% |
82 / 221 |
|
33.33% |
3 / 9 |
698.15 | |
0.00% |
0 / 1 |
__construct | |
59.09% |
26 / 44 |
|
0.00% |
0 / 1 |
33.53 | |||
resetQuizID | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getQuizId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getSettingsTable | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
8 | |||
parseQuiz | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
6 | |||
parseIncludes | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
parseInclude | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
parseQuestions | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
30 | |||
parseQuestion | |
30.00% |
24 / 80 |
|
0.00% |
0 / 1 |
81.23 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\Quiz; |
4 | |
5 | use MediaWiki\Extension\Quiz\Hooks\HookRunner; |
6 | use MediaWiki\Html\TemplateParser; |
7 | use MediaWiki\MediaWikiServices; |
8 | use MediaWiki\Parser\Parser; |
9 | use MediaWiki\Request\WebRequest; |
10 | use MediaWiki\Title\Title; |
11 | use StringUtils; |
12 | |
13 | /** |
14 | * Processes quiz markup |
15 | */ |
16 | class 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(); |
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 | } |