diff --git a/question/type/ordering/classes/output/specific_grade_detail_feedback.php b/question/type/ordering/classes/output/specific_grade_detail_feedback.php new file mode 100644 index 00000000000..2d01bad480e --- /dev/null +++ b/question/type/ordering/classes/output/specific_grade_detail_feedback.php @@ -0,0 +1,108 @@ +. + +namespace qtype_ordering\output; + +use qtype_ordering_question; + +/** + * Renderable class for the displaying the grade detail of the response. + * + * @package qtype_ordering + * @copyright 2023 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class specific_grade_detail_feedback extends renderable_base { + + /** + * Export the data for the mustache template. + * + * @param \renderer_base $output renderer to be used to render the action bar elements. + * @return array + */ + public function export_for_template(\renderer_base $output): array { + + $data = []; + $question = $this->qa->get_question(); + + // Decide if we should show grade explanation for "partial" or "wrong" states. + // This should detect "^graded(partial|wrong)$" and possibly others. + $showpartialwrong = false; + if ($step = $this->qa->get_last_step()) { + $showpartialwrong = preg_match('/(partial|wrong)$/', $step->get_state()); + } + $data['showpartialwrong'] = $showpartialwrong; + if (!$showpartialwrong) { + return $data; + } + + $plugin = 'qtype_ordering'; + + // Show grading details if they are required. + if ($question->options->showgrading) { + // Fetch grading type. + $gradingtype = $question->options->gradingtype; + $gradingtype = qtype_ordering_question::get_grading_types($gradingtype); + + // Format grading type, e.g. Grading type: Relative to next item, excluding last item. + if ($gradingtype) { + $data['gradingtype'] = get_string('gradingtype', $plugin) . ': ' . $gradingtype; + } + + // Fetch grade details and score details. + if ($currentresponse = $question->currentresponse) { + + $totalscore = 0; + $totalmaxscore = 0; + + $data['orderinglayoutclass'] = $question->get_ordering_layoutclass(); + + // Format scoredetails, e.g. 1 /2 = 50%, for each item. + foreach ($currentresponse as $position => $answerid) { + if (array_key_exists($answerid, $question->answers)) { + $score = $question->get_ordering_item_score($question, $position, $answerid); + [$score, $maxscore, $fraction, $percent, $class] = $score; + if (!isset($maxscore)) { + $score = get_string('noscore', $plugin); + } else { + $totalscore += $score; + $totalmaxscore += $maxscore; + } + $data['scoredetails'][] = [ + 'score' => $score, + 'maxscore' => $maxscore, + 'percent' => $percent, + ]; + } + } + + if ($totalmaxscore == 0) { + unset($data['scoredetails']); // All or nothing. + } else { + // Format gradedetails, e.g. 4/6 = 67%. + if ($totalscore == 0) { + $data['gradedetails'] = 0; + } else { + $data['gradedetails'] = round(100 * $totalscore / $totalmaxscore, 0); + } + $data['totalscore'] = $totalscore; + $data['totalmaxscore'] = $totalmaxscore; + } + } + } + return $data; + } +} diff --git a/question/type/ordering/question.php b/question/type/ordering/question.php index ae09a88a6b4..f7b0a288708 100644 --- a/question/type/ordering/question.php +++ b/question/type/ordering/question.php @@ -93,6 +93,9 @@ class qtype_ordering_question extends question_graded_automatically { /** @var array contatining current order of answerids */ public $currentresponse; + /** @var array of scored for every item */ + protected $itemscores = []; + /** * Start a new attempt at this question, storing any information that will * be needed later in the step. @@ -856,7 +859,8 @@ class qtype_ordering_question extends question_graded_automatically { list($correctresponse, $currentresponse) = $this->get_response_depend_on_grading_type($gradingtype); foreach ($this->currentresponse as $position => $answerid) { - $fraction = $this->get_fraction_of_item($position, $answerid, $correctresponse, $currentresponse); + [$fraction, $score, $maxscore] = + $this->get_fraction_maxscore_score_of_item($position, $answerid, $correctresponse, $currentresponse); if (is_null($fraction)) { continue; } @@ -880,14 +884,14 @@ class qtype_ordering_question extends question_graded_automatically { * @param int $answerid The answerid of the current response. * @param array $correctresponse The correct response list base on grading type. * @param array $currentresponse The current response list base on grading type. - * @return float|null Float if the grade, base on the fraction scale and null if the item is not in the correct response. + * @return array. */ - protected function get_fraction_of_item( + protected function get_fraction_maxscore_score_of_item( int $position, int $answerid, array $correctresponse, array $currentresponse - ): float|null { + ): array { $gradingtype = $this->options->gradingtype; $score = 0; @@ -952,7 +956,7 @@ class qtype_ordering_question extends question_graded_automatically { } $fraction = $maxscore ? $score / $maxscore : $maxscore; - return $fraction; + return [$fraction, $score, $maxscore]; } /** @@ -1005,4 +1009,49 @@ class qtype_ordering_question extends question_graded_automatically { return [$correctresponse, $currentresponse]; } + + /** + * Returns score for one item depending on correctness and question settings. + * + * @param question_definition $question question definition object + * @param int $position The position of the current response. + * @param int $answerid The answerid of the current response. + * @return array (score, maxscore, fraction, percent, class) + */ + public function get_ordering_item_score(question_definition $question, int $position, int $answerid): array { + + if (!isset($this->itemscores[$position])) { + + [$correctresponse, $currentresponse] = $this->get_response_depend_on_grading_type($question->options->gradingtype); + + $percent = 0; // 100 * $fraction. + [$fraction, $score, $maxscore] = + $this->get_fraction_maxscore_score_of_item($position, $answerid, $correctresponse, $currentresponse); + + if ($maxscore === null) { + // An unscored item is either an illegal item + // or last item of RELATIVE_NEXT_EXCLUDE_LAST + // or an item in an incorrect ALL_OR_NOTHING + // or an item from an unrecognized grading type. + $class = 'unscored'; + } else { + if ($maxscore > 0) { + $percent = round(100 * $fraction, 0); + } + $class = match (true) { + $fraction > 0.999999 => 'correct', + $fraction < 0.000001 => 'incorrect', + $fraction >= 0.66 => 'partial66', + $fraction >= 0.33 => 'partial33', + default => 'partial00', + }; + } + + $score = [$score, $maxscore, $fraction, $percent, $class]; + $this->itemscores[$position] = $score; + } + + return $this->itemscores[$position]; + } + } diff --git a/question/type/ordering/renderer.php b/question/type/ordering/renderer.php index 1bf1eb1f24f..bd2a8e1899b 100644 --- a/question/type/ordering/renderer.php +++ b/question/type/ordering/renderer.php @@ -138,6 +138,7 @@ class qtype_ordering_renderer extends qtype_with_combined_feedback_renderer { switch ($options->correctness) { case question_display_options::VISIBLE: $score = $this->get_ordering_item_score($question, $position, $answerid); + // To do: we need image calculation in MDL-79873. list($score, $maxscore, $fraction, $percent, $class, $img) = $score; $class = trim("$sortableitem $class"); break; @@ -154,6 +155,7 @@ class qtype_ordering_renderer extends qtype_with_combined_feedback_renderer { if (isset($options->highlightresponse) && $options->highlightresponse) { $score = $this->get_ordering_item_score($question, $position, $answerid); + // To do: we need image calculation here in MDL-79873. list($score, $maxscore, $fraction, $percent, $class, $img) = $score; $class = trim("$sortableitem $class"); } @@ -246,86 +248,9 @@ class qtype_ordering_renderer extends qtype_with_combined_feedback_renderer { * @return string Output grade detail of the response. */ public function specific_grade_detail_feedback(question_attempt $qa): string { - $gradingtype = ''; - $gradedetails = ''; - $scoredetails = ''; - - // Decide if we should show grade explanation for "partial" or "wrong" states. - // This should detect "^graded(partial|wrong)$" and possibly others. - if ($step = $qa->get_last_step()) { - $show = preg_match('/(partial|wrong)$/', $step->get_state()); - } else { - $show = false; - } - - // If required, add explanation of grade calculation. - if ($show) { - - $plugin = 'qtype_ordering'; - $question = $qa->get_question(); - - // Show grading details if they are required. - if ($question->options->showgrading) { - - // Fetch grading type. - $gradingtype = $question->options->gradingtype; - $gradingtype = qtype_ordering_question::get_grading_types($gradingtype); - - // Format grading type, e.g. Grading type: Relative to next item, excluding last item. - if ($gradingtype) { - $gradingtype = get_string('gradingtype', $plugin).': '.$gradingtype; - $gradingtype = html_writer::tag('p', $gradingtype, array('class' => 'gradingtype')); - } - - // Fetch grade details and score details. - if ($currentresponse = $question->currentresponse) { - - $totalscore = 0; - $totalmaxscore = 0; - - $sortableitem = $question->get_ordering_layoutclass(); - $params = array('class' => $sortableitem); - - $scoredetails .= html_writer::tag('p', get_string('scoredetails', $plugin)); - $scoredetails .= html_writer::start_tag('ol', array('class' => 'scoredetails')); - - // Format scoredetails, e.g. 1 /2 = 50%, for each item. - foreach ($currentresponse as $position => $answerid) { - if (array_key_exists($answerid, $question->answers)) { - $answer = $question->answers[$answerid]; - $score = $this->get_ordering_item_score($question, $position, $answerid); - list($score, $maxscore, $fraction, $percent, $class, $img) = $score; - if ($maxscore === null) { - $score = get_string('noscore', $plugin); - } else { - $totalscore += $score; - $totalmaxscore += $maxscore; - $score = "$score / $maxscore = $percent%"; - } - $scoredetails .= html_writer::tag('li', $score, $params); - } - } - - $scoredetails .= html_writer::end_tag('ol'); - - if ($totalmaxscore == 0) { - $scoredetails = ''; // ALL_OR_NOTHING. - } else { - // Format gradedetails, e.g. 4 /6 = 67%. - if ($totalscore == 0) { - $gradedetails = 0; - } else { - $gradedetails = round(100 * $totalscore / $totalmaxscore, 0); - } - $gradedetails = "$totalscore / $totalmaxscore = $gradedetails%"; - $gradedetails = get_string('gradedetails', $plugin).': '.$gradedetails; - $gradedetails = html_writer::tag('p', $gradedetails, array('class' => 'gradedetails')); - } - } - } - } - - return $gradingtype.$gradedetails.$scoredetails; + $specificgradedetailfeedback = new \qtype_ordering\output\specific_grade_detail_feedback($qa); + return $this->output->render_from_template('qtype_ordering/specific_grade_detail_feedback', + $specificgradedetailfeedback->export_for_template($this->output)); } /** @@ -358,6 +283,7 @@ class qtype_ordering_renderer extends qtype_with_combined_feedback_renderer { /** * Fills $this->correctinfo and $this->currentinfo depending on question options. + * TO DO: REMOVE ME in MDL-79873 * * @param object $question */ @@ -407,6 +333,8 @@ class qtype_ordering_renderer extends qtype_with_combined_feedback_renderer { /** * Returns score for one item depending on correctness and question settings. * + * TO DO: REMOVE ME in MDL-79873 + * * @param object $question * @param int $position * @param int $answerid @@ -539,6 +467,7 @@ class qtype_ordering_renderer extends qtype_with_combined_feedback_renderer { /** * Return true if answer is 100% correct. + * TO DO: REMOVE ME in MDL-79873 * * @return bool */ diff --git a/question/type/ordering/templates/specific_grade_detail_feedback.mustache b/question/type/ordering/templates/specific_grade_detail_feedback.mustache new file mode 100644 index 00000000000..f09928bd75d --- /dev/null +++ b/question/type/ordering/templates/specific_grade_detail_feedback.mustache @@ -0,0 +1,70 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template qtype_ordering/specific_grade_detail_feedback + + Renders the grade detail of the response. + + Context variables required for this template: + * showpartialwrong - Whether to show grade details. + * gradingtype - Grading type. + * gradedetails - Total score (percent). + * orderinglayoutclass - The ordering layout CSS class. + * scoredetails - An array containing the score details. + + Example context (json): + { + "showpartialwrong": true, + "gradingtype": "Grading type: Absolute position", + "gradedetails": "93", + "orderinglayoutclass": "vertical", + "scoredetails": [ + { + "score": "1", + "maxscore": "1", + "percent": "100" + }, + { + "score": "0", + "maxscore": "1", + "percent": "0" + } + ] + } +}} + +{{#showpartialwrong}} + {{#gradingtype}} +

+ {{gradingtype}} +

+ {{/gradingtype}} + {{#gradedetails}} +

+ {{#str}} gradedetails, qtype_ordering {{/str}}: + {{totalscore}} / {{totalmaxscore}} = {{gradedetails}}% +

+ {{/gradedetails}} + {{#str}} scoredetails, qtype_ordering {{/str}} +
    + {{#scoredetails}} +
  1. + {{score}} / {{maxscore}} = {{percent}}% +
  2. + {{/scoredetails}} +
+{{/showpartialwrong}} diff --git a/question/type/ordering/tests/output/specific_grade_detail_feedback_test.php b/question/type/ordering/tests/output/specific_grade_detail_feedback_test.php new file mode 100644 index 00000000000..77cb420e931 --- /dev/null +++ b/question/type/ordering/tests/output/specific_grade_detail_feedback_test.php @@ -0,0 +1,160 @@ +. + +namespace qtype_ordering\output; + +use advanced_testcase; +use test_question_maker; +use qtype_ordering_question; +use qtype_ordering_test_helper; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); + +/** + * A test class used to test specific_grade_detail_feedback. + * + * @package qtype_ordering + * @copyright 2023 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \qtype_ordering\output\specific_grade_detail_feedback + */ +class specific_grade_detail_feedback_test extends advanced_testcase { + + /** + * Test the exported data for the template that renders the specific grade detail feedback test to a given question attempt. + * + * @dataProvider export_for_template_provider + * @param array $answeritems The array of ordered answers. + * @param int $gradingtype Grading type. + * @param string $layouttype The type of the layout. + * @param array $expected The expected exported data. + * @return void + * @covers ::export_for_template + */ + public function test_export_for_template(array $answeritems, int $gradingtype, string $layouttype, array $expected): void { + global $PAGE; + + $question = test_question_maker::make_question('ordering'); + $question->options->layouttype = $layouttype === 'horizontal' ? qtype_ordering_question::LAYOUT_HORIZONTAL : + qtype_ordering_question::LAYOUT_VERTICAL; + $qa = new \testable_question_attempt($question, 0); + $step = new \question_attempt_step(); + $qa->add_step($step); + $question->start_attempt($step, 1); + $question->options->gradingtype = $gradingtype; + + $keys = implode(',', array_keys($answeritems)); + $values = array_values($answeritems); + + $step->set_qt_var('_currentresponse', $keys); + + list($fraction, $state) = $question->grade_response(qtype_ordering_test_helper::get_response($question, $values)); + $qa->get_last_step()->set_state($state); + + $renderer = $PAGE->get_renderer('core'); + $specificgradedetailfeedback = new specific_grade_detail_feedback($qa); + $actual = $specificgradedetailfeedback->export_for_template($renderer); + + $this->assertEquals($expected, $actual); + } + + /** + * Data provider for the test_export_for_template test. + * + * @return array + */ + public function export_for_template_provider(): array { + global $CFG; + require_once($CFG->dirroot . '/question/type/ordering/question.php'); + + return [ + 'Do not show partial or wrong' => [ + [13 => 'Modular', 14 => 'Object', 15 => 'Oriented', 16 => 'Dynamic', 17 => 'Learning', 18 => 'Environment'], + qtype_ordering_question::GRADING_RELATIVE_NEXT_EXCLUDE_LAST, + 'horizontal', + [ + 'showpartialwrong' => false, + ], + ], + 'Partially correct question attempt (horizontal layout). Relative to ALL the previous and next items' => [ + [13 => 'Modular', 14 => 'Object', 15 => 'Oriented', 17 => 'Learning', 16 => 'Dynamic', 18 => 'Environment'], + qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT, + 'horizontal', + [ + 'showpartialwrong' => 1, + 'gradingtype' => 'Grading type: Relative to ALL the previous and next items', + 'orderinglayoutclass' => 'horizontal', + 'gradedetails' => 93.0, + 'totalscore' => 28, + 'totalmaxscore' => 30, + 'scoredetails' => [ + ['score' => 5, 'maxscore' => 5, 'percent' => 100], + ['score' => 5, 'maxscore' => 5, 'percent' => 100], + ['score' => 5, 'maxscore' => 5, 'percent' => 100], + ['score' => 4, 'maxscore' => 5, 'percent' => 80], + ['score' => 4, 'maxscore' => 5, 'percent' => 80], + ['score' => 5, 'maxscore' => 5, 'percent' => 100], + ], + ], + ], + 'Incorrect question attempt (horizontal layout). Relative to ALL the previous and next items' => [ + [14 => 'Object', 16 => 'Dynamic', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], + qtype_ordering_question::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT, + 'horizontal', + [ + 'showpartialwrong' => 1, + 'gradingtype' => 'Grading type: Relative to ALL the previous and next items', + 'orderinglayoutclass' => 'horizontal', + 'gradedetails' => 67.0, + 'totalscore' => 20, + 'totalmaxscore' => 30, + 'scoredetails' => [ + ['score' => 4, 'maxscore' => 5, 'percent' => 80], + ['score' => 3, 'maxscore' => 5, 'percent' => 60], + ['score' => 3, 'maxscore' => 5, 'percent' => 60], + ['score' => 4, 'maxscore' => 5, 'percent' => 80], + ['score' => 4, 'maxscore' => 5, 'percent' => 80], + ['score' => 2, 'maxscore' => 5, 'percent' => 40], + ], + ], + ], + 'Incorrect question attempt (vertical layout). Grading type: Relative to the next item (excluding last)' => [ + [14 => 'Object', 16 => 'Dynamic', 13 => 'Modular', 17 => 'Learning', 18 => 'Environment', 15 => 'Oriented'], + qtype_ordering_question::GRADING_RELATIVE_NEXT_EXCLUDE_LAST, + 'vertical', + [ + 'showpartialwrong' => 1, + 'gradingtype' => 'Grading type: Relative to the next item (excluding last)', + 'orderinglayoutclass' => 'vertical', + 'gradedetails' => 20.0, + 'totalscore' => 1, + 'totalmaxscore' => 5, + 'scoredetails' => [ + ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], + ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], + ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], + ['score' => 1, 'maxscore' => 1, 'percent' => 100.0], + ['score' => 'No score', 'maxscore' => null, 'percent' => 0], + ['score' => 0, 'maxscore' => 1, 'percent' => 0.0], + ], + ], + ], + ]; + } +}