Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
33.33% covered (danger)
33.33%
3 / 9
CRAP
38.22% covered (danger)
38.22%
73 / 191
Quiz
0.00% covered (danger)
0.00%
0 / 1
33.33% covered (danger)
33.33%
3 / 9
639.50
38.22% covered (danger)
38.22%
73 / 191
 __construct
0.00% covered (danger)
0.00%
0 / 1
39.70
54.76% covered (warning)
54.76%
23 / 42
 resetQuizID
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 getQuizId
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 getSettingsTable
100.00% covered (success)
100.00%
1 / 1
8
100.00% covered (success)
100.00%
25 / 25
 parseQuiz
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 18
 parseIncludes
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 3
 parseInclude
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 12
 parseQuestions
0.00% covered (danger)
0.00%
0 / 1
30
0.00% covered (danger)
0.00%
0 / 19
 parseQuestion
0.00% covered (danger)
0.00%
0 / 1
66.41
31.88% covered (danger)
31.88%
22 / 69
<?php
namespace MediaWiki\Extension\Quiz;
use Hooks as MWHooks;
use Parser;
use StringUtils;
use TemplateParser;
use Title;
use WebRequest;
/**
 * Processes quiz markup
 */
class Quiz {
    /** @var int */
    private static $sQuizId = 0;
    /** @var Parser */
    private $mParser;
    /** @var WebRequest */
    private $mRequest;
    /** @var int */
    private $mQuizId;
    /** @var int */
    private $mQuestionId;
    /** @var int */
    private $mShuffleDiv;
    /** @var bool */
    private $mBeingCorrected;
    /** @var string */
    private $mState;
    /** @var int */
    private $numberQuestions;
    /** @var int */
    private $mTotal;
    /** @var int */
    private $mScore;
    /** @var int */
    private $mAddedPoints;
    /** @var int */
    private $mCutoffPoints;
    /** @var bool */
    private $mIgnoringCoef;
    /** @var bool */
    private $mDisplaySimple;
    /** @var bool */
    private $shuffleAnswers;
    /** @var bool */
    private $mShuffle;
    /** @var bool */
    private $mCaseSensitive;
    /** @var string */
    private $mIncludePattern;
    /**
     * @param array $argv
     * @param Parser $parser
     */
    public function __construct( $argv, Parser $parser ) {
        global $wgRequest;
        $this->mParser = $parser;
        $this->mRequest = $wgRequest;
        // Allot a unique identifier to the quiz.
        $this->mQuizId = $this->getQuizId();
        self::$sQuizId++;
        // Reset the unique identifier of the questions.
        $this->mQuestionId = 0;
        // Reset the counter of div "shuffle" or "noshuffle" inside the quiz.
        $this->mShuffleDiv = 0;
        // Determine if this quiz is being corrected or not, according to the quizId
        $this->mBeingCorrected = ( $wgRequest->getVal( 'quizId' ) == strval( $this->mQuizId ) );
        // Initialize various parameters used for the score calculation
        $this->mState = 'NA';
        $this->numberQuestions = 0;
        $this->mTotal = $this->mScore = 0;
        $this->mAddedPoints = 1;
        $this->mCutoffPoints = 0;
        $this->mIgnoringCoef = false;
        $this->mDisplaySimple = ( array_key_exists( 'display', $argv ) &&
            $argv['display'] == 'simple' );
        $this->shuffleAnswers = ( array_key_exists( 'shuffleanswers', $argv ) &&
            $argv['shuffleanswers'] == 'true' );
        if ( $this->mBeingCorrected ) {
            $lAddedPoints = str_replace( ',', '.',
                $this->mRequest->getVal( 'addedPoints' )
            );
            if ( is_numeric( $lAddedPoints ) ) {
                $this->mAddedPoints = $lAddedPoints;
            }
            $lCutoffPoints = str_replace( ',', '.',
                $this->mRequest->getVal( 'cutoffPoints' )
            );
            if ( is_numeric( $lCutoffPoints ) ) {
                $this->mCutoffPoints = $lCutoffPoints;
            }
            if ( $this->mRequest->getVal( 'ignoringCoef' ) == 'on' ) {
                $this->mIgnoringCoef = true;
            }
        }
        if ( array_key_exists( 'points', $argv ) &&
            ( !$this->mBeingCorrected || $this->mDisplaySimple ) &&
            preg_match(
                '`([\d\.]*)/?([\d\.]*)(!)?`', str_replace( ',', '.', $argv['points'] ), $matches
            )
        ) {
            if ( is_numeric( $matches[1] ) ) {
                $this->mAddedPoints = $matches[1];
            }
            if ( is_numeric( $matches[2] ) ) {
                $this->mCutoffPoints = $matches[2];
            }
            if ( array_key_exists( 3, $matches ) ) {
                $this->mIgnoringCoef = true;
            }
        }
        $this->mShuffle = !( array_key_exists( 'shuffle', $argv ) && $argv['shuffle'] == 'none' );
        $this->mCaseSensitive = !( array_key_exists( 'case', $argv ) && $argv['case'] == '(i)' );
        // Patterns used in several places
        $this->mIncludePattern = '`^\{\{:?(.*)\}\}[ \t]*`m';
    }
    /**
     * @return bool
     */
    public static function resetQuizID() {
        self::$sQuizId = 0;
        return true;
    }
    /**
     * @return int Quiz Id
     */
    public function getQuizId() {
        return self::$sQuizId;
    }
    /**
     * Get HTML from template using TemplateParser
     *
     * @param TemplateParser $templateParser
     * @return string
     */
    public function getSettingsTable( $templateParser ) {
        $checked = $this->mIgnoringCoef ? 'checked="checked"' : '';
        $settingsTable = $templateParser->processTemplate(
            'Setting',
            [
                'isSettingFirstRow' => ( !$this->mDisplaySimple || $this->mBeingCorrected ||
                    $this->mState === 'error' ),
                'isSettingOtherRow' => ( !$this->mDisplaySimple || $this->mBeingCorrected ),
                'notSimple' => !$this->mDisplaySimple,
                /** @phan-suppress-next-line PhanPluginDuplicateExpressionBinaryOp FIXME */
                'corrected' => ( $this->mBeingCorrected && $this->mBeingCorrected ),
                'shuffle' => $this->mShuffle,
                'shuffleOrError' => ( $this->mShuffle && $this->numberQuestions > 1 ) ||
                    $this->mState === 'error',
                'error' => $this->mState === 'error',
                'wfMessage' => [
                    'quiz_added' => wfMessage( 'quiz_addedPoints', $this->mAddedPoints )->text(),
                    'quiz_cutoff' => wfMessage( 'quiz_cutoffPoints', $this->mCutoffPoints )->text(),
                    'quiz_ignoreCoef' => wfMessage( 'quiz_ignoreCoef' )->text(),
                    'quiz_legend_correct' => wfMessage( 'quiz_legend_correct' )->text(),
                    'quiz_legend_incorrect' => wfMessage( 'quiz_legend_incorrect' )->text(),
                    'quiz_legend_unanswered' => wfMessage( 'quiz_legend_unanswered' )->text(),
                    'quiz_legend_error' => wfMessage( 'quiz_legend_error' )->text(),
                    'quiz_shuffle' => wfMessage( 'quiz_shuffle' )->text()
                ],
                'mAddedPoints' => $this->mAddedPoints,
                'mCutoffPoints' => $this->mCutoffPoints,
                'checked' => $checked,
                'shuffleDisplay' => $this->numberQuestions > 1
            ]
        );
        return $settingsTable;
    }
    /**
     * Convert the input text to an HTML output.
     *
     * @param string $input text between <quiz> and </quiz> tags, in quiz syntax.
     * @return string
     */
    public function parseQuiz( $input ) {
        // Ouput the style and the script to the header once for all.
        if ( $this->mQuizId == 0 ) {
            $this->mParser->getOutput()->addModules( [ 'ext.quiz' ] );
            $this->mParser->getOutput()->addModuleStyles( [ 'ext.quiz.styles' ] );
        }
        // Process the input
        $input = $this->parseQuestions( $this->parseIncludes( $input ) );
        // Generates the output.
        $templateParser = new TemplateParser( __DIR__ . '/../templates' );
        // Determine the content of the settings table.
        $settingsTable = $this->getSettingsTable( $templateParser );
        $quiz_score = wfMessage( 'quiz_score' )->rawParams(
            '<span class="score">' . $this->mScore . '</span>',
            '<span class="total">' . $this->mTotal . '</span>' )->escaped();
        return $templateParser->processTemplate(
            'Quiz',
            [
                'quiz' => [
                    'id' => $this->mQuizId,
                    'beingCorrected' => $this->mBeingCorrected,
                    'questions' => $input
                ],
                'settingsTable' => $settingsTable,
                'wfMessage' => [
                    'quiz_correction' => wfMessage( 'quiz_correction' )->escaped(),
                    'quiz_reset' => wfMessage( 'quiz_reset' )->escaped(),
                    'quiz_score' => $quiz_score
                ]
            ]
        );
    }
    /**
     * Replace inclusions from other quizzes.
     *
     * @param string $input text between <quiz> and </quiz> tags, in quiz syntax.
     * @return string
     */
    private function parseIncludes( $input ) {
        return preg_replace_callback(
            $this->mIncludePattern,
            [ $this, 'parseInclude' ],
            $input
        );
    }
    /**
     * Include text between <quiz> and <quiz> from another page to this quiz.
     *
     * @param array $matches elements matching $includePattern
     *                             $matches[1] is the page title.
     * @return mixed|string
     */
    private function parseInclude( $matches ) {
        $title = Title::makeTitleSafe( NS_MAIN, $matches[1] );
        if ( $title === null ) {
            // Not a valid title for this include; replace w/ empty string.
            return '';
        }
        $text = $this->mParser->fetchTemplateAndTitle( $title )[0];
        $output = '';
        if ( preg_match( '`<quiz[^>]*>(.*?)</quiz>`sU', $text, $unparsedQuiz ) ) {
            // Remove inclusions from included quiz.
            $output = preg_replace(
                $this->mIncludePattern,
                '',
                StringUtils::escapeRegexReplacement( $unparsedQuiz[1] )
            );
            $output .= "\n";
        }
        return $output;
    }
    /**
     * Replace questions from quiz syntax to HTML.
     *
     * @param string $input a question in quiz syntax.
     * @return string
     */
    private function parseQuestions( $input ) {
        $splitPattern = '`(^|\n[ \t]*)\n\{`';
        $unparsedQuestions = preg_split(
            $splitPattern,
            $input,
            -1,
            PREG_SPLIT_NO_EMPTY
        );
        $output = '';
        $questionPattern = '`(.*?[^|\}])\}[ \t]*(\n(.*)|$)`s';
        $this->numberQuestions = count( $unparsedQuestions );
        $numDisplay = $this->numberQuestions > 1;
        foreach ( $unparsedQuestions as $unparsedQuestion ) {
            // If this "unparsedQuestion" is not a full question,
            // we put the text into a buffer to add it at the beginning of the next question.
            if ( !empty( $buffer ) ) {
                $unparsedQuestion = $buffer . "\n\n" . '{' . $unparsedQuestion;
            }
            if ( preg_match( $questionPattern, $unparsedQuestion, $matches ) ) {
                $buffer = '';
                $output .= $this->parseQuestion( $matches, $numDisplay );
            } else {
                $buffer = $unparsedQuestion;
            }
        }
        // Close unclosed "shuffle" or "noshuffle" tags.
        while ( $this->mShuffleDiv > 0 ) {
            $output .= '</div>';
            $this->mShuffleDiv--;
        }
        return $output;
    }
    /**
     * Convert a question from quiz syntax to HTML
     *
     * @param array $matches elements matching $questionPattern
     *                         $matches[1] is the question header.
     *                         $matches[3] is the question object.
     * @param bool $numDisplay specifies whether to display question number.
     * @return string
     */
    public function parseQuestion( $matches, $numDisplay ) {
        $question = new Question(
            $this->mBeingCorrected,
            $this->mCaseSensitive,
            $this->mQuestionId,
            $this->shuffleAnswers,
            $this->mParser
        );
        MWHooks::run( 'QuizQuestionCreated', [ $this, &$question ] );
        // gets the question text
        $questionText = $question->parseHeader( $matches[1] );
        /*
            What is this block of code?
            The only place X !X and /X are spoken about is here
            https://en.wikiversity.org/wiki/Help:Quiz
            "A few exotic features are not yet covered,
            such as shuffle control using {X} {!X} {/X} tags."
            These were added in commit fb53a3b0 back in 2007,
            without any explanation and/or documentation. The commit message is actually unrelated.
        */
        if ( !array_key_exists( 3, $matches ) || trim( $matches[3] ) == '' ) {
            switch ( $matches[1] ) {
                case 'X':
                    $this->mShuffleDiv++;
                    return '<div class="shuffle">' . "\n";
                case '!X':
                    $this->mShuffleDiv++;
                    return '<div class="noshuffle">' . "\n";
                case '/X':
                    // Prevent closing of other tags.
                    if ( $this->mShuffleDiv == 0 ) {
                        return '';
                    } else {
                        $this->mShuffleDiv--;
                        return '</div>' . "\n";
                    }
                default:
                    return '<div class="quizText">' . $questionText . '<br /></div>' . "\n";
            }
        }
        $templateParser = new TemplateParser( __DIR__ . '/../templates' );
        $this->mQuestionId++;
        // This will generate the answers HTML code
        $answers = call_user_func(
            // Calling singleChoiceParseObject, multipleChoiceParseObject and textFieldParseObject
            [ $question, $question->mType . 'ParseObject' ],
            $matches[3]
        );
        // Set default table title and style
        $tableTitle = "";
        $lState = $question->getState(); // correct, incorrect or unanswered?
        if ( $lState != '' ) {
            // if the question is of type=simple
            if ( $this->mIgnoringCoef ) {
                $question->mCoef = 1;
            }
            switch ( $lState ) {
                case 'correct':
                    $this->mTotal += $this->mAddedPoints * $question->mCoef;
                    $this->mScore += $this->mAddedPoints * $question->mCoef;
                    $tableTitle = wfMessage(
                        'quiz_points',
                        wfMessage( 'quiz_legend_correct' )->text(),
                        $this->mAddedPoints * $question->mCoef
                    )->escaped();
                    break;
                case 'incorrect':
                    $this->mTotal += $this->mAddedPoints * $question->mCoef;
                    $this->mScore -= $this->mCutoffPoints * $question->mCoef;
                    $tableTitle = wfMessage(
                        'quiz_points',
                        wfMessage( 'quiz_legend_incorrect' )->text(),
                        -$this->mCutoffPoints * $question->mCoef
                    )->escaped();
                    break;
                case 'NA':
                    $this->mTotal += $this->mAddedPoints * $question->mCoef;
                    $tableTitle = wfMessage(
                        'quiz_points',
                        wfMessage( 'quiz_legend_unanswered' )->text(),
                        0
                    )->escaped();
                    break;
                case 'error':
                    $this->mState = 'error';
                    break;
            }
        }
        $stateObject = [
            'state' => $lState,
            'tableTitle' => $tableTitle
        ];
        return $templateParser->processTemplate(
            'Question',
            [
                'question' => [
                    'id' => $this->mQuestionId,
                    'numdis' => $numDisplay,
                    'text' => $questionText,
                    'answers' => $answers
                ],
                'state' => $stateObject
            ]
        );
    }
}