MDL-79875 qtype_ordering: Template to output grade detail

Part of: MDL-79863
Creates an exporter class and a template to output the grade
detail to a given question attempt.
This commit is contained in:
Ilya Tregubov 2023-11-07 15:34:31 +08:00 committed by Mathew May
parent 31fc5161c9
commit de5fc46b41
5 changed files with 401 additions and 85 deletions

View File

@ -0,0 +1,108 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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 <ilya.a.tregubov@gmail.com>
* @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;
}
}

View File

@ -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];
}
}

View File

@ -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
*/

View File

@ -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 <http://www.gnu.org/licenses/>.
}}
{{!
@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}}
<p class="gradingtype">
{{gradingtype}}
</p>
{{/gradingtype}}
{{#gradedetails}}
<p class="gradedetails">
{{#str}} gradedetails, qtype_ordering {{/str}}:
{{totalscore}} / {{totalmaxscore}} = {{gradedetails}}%
</p>
{{/gradedetails}}
{{#str}} scoredetails, qtype_ordering {{/str}}
<ol class="scoredetails">
{{#scoredetails}}
<li class="{{orderinglayoutclass}}">
{{score}} / {{maxscore}} = {{percent}}%
</li>
{{/scoredetails}}
</ol>
{{/showpartialwrong}}

View File

@ -0,0 +1,160 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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 <ilya.a.tregubov@gmail.com.com>
* @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],
],
],
],
];
}
}