This commit is contained in:
Sara Arjona
2022-05-31 17:39:03 +02:00
25 changed files with 617 additions and 38 deletions
+1
View File
@@ -58,6 +58,7 @@ $string['cannotmovequestion'] = 'You can\'t use this script to move questions th
$string['cannotopenforwriting'] = 'Cannot open for writing: {$a}';
$string['cannotpreview'] = 'You can\'t preview these questions!';
$string['cannotread'] = 'Cannot read import file (or file is empty)';
$string['cannotregradedifferentqtype'] = 'Cannot regrade with a question of a different type.';
$string['cannotretrieveqcat'] = 'Could not retrieve question category';
$string['cannotunhidequestion'] = 'Failed to unhide the question.';
$string['cannotunzip'] = 'Could not unzip file.';
@@ -63,11 +63,13 @@ $string['regradealldrydo'] = 'Regrade attempts marked as needing regrading ({$a}
$string['regradealldrydogroup'] = 'Regrade attempts ({$a->countregradeneeded}) marked as needing regrading in group \'{$a->groupname}\'';
$string['regradealldrygroup'] = 'Dry run a full regrade for group \'{$a->groupname}\'';
$string['regradeallgroup'] = 'Full regrade for group \'{$a->groupname}\'';
$string['regradecomplete'] = 'Regrade completed successfully';
$string['regradedsuccessfullyxofy'] = 'Successfully regraded ({$a->done}/{$a->count})';
$string['regradecomplete'] = 'Regrade completed';
$string['regradedsuccessfullyxofy'] = 'Finished regrading ({$a->done}/{$a->count})';
$string['regradeheader'] = 'Regrading';
$string['regradeselected'] = 'Regrade selected attempts';
$string['regradingattemptissue'] = 'Slot {$a->slot}: {$a->reason}';
$string['regradingattemptxofy'] = 'Regrading attempt ({$a->done}/{$a->count})';
$string['regradingattemptxofyproblem'] = 'The following questions could not be regraded in attempt {$a->attemptnum} by {$a->name} (id {$a->attemptid})';
$string['regradingattemptxofywithdetails'] = 'Regrading attempt ({$a->done}/{$a->count}) - Attempt {$a->attemptnum} by {$a->name} (id {$a->attemptid})';
$string['show'] = 'Show / download';
$string['showattempts'] = 'Only show / download attempts';
+22 -2
View File
@@ -337,8 +337,9 @@ class quiz_overview_report extends quiz_attempts_report {
* @param bool $dryrun if true, do a pretend regrade, otherwise do it for real.
* @param array $slots if null, regrade all questions, otherwise, just regrade
* the questions with those slots.
* @return array messages array with keys slot number, and values reasons why that slot cannot be regraded.
*/
public function regrade_attempt($attempt, $dryrun = false, $slots = null) {
public function regrade_attempt($attempt, $dryrun = false, $slots = null): array {
global $DB;
// Need more time for a quiz with many questions.
core_php_time_limit::raise(300);
@@ -351,12 +352,19 @@ class quiz_overview_report extends quiz_attempts_report {
$slots = $quba->get_slots();
}
$messages = [];
$finished = $attempt->state == quiz_attempt::FINISHED;
foreach ($slots as $slot) {
$qqr = new stdClass();
$qqr->oldfraction = $quba->get_question_fraction($slot);
$otherquestionversion = $this->get_new_question_for_regrade($attempt, $quba, $slot);
$message = $quba->validate_can_regrade_with_other_version($slot, $otherquestionversion);
if ($message) {
$messages[$slot] = $message;
continue;
}
$quba->regrade_question($slot, $finished, null, $otherquestionversion);
$qqr->newfraction = $quba->get_question_fraction($slot);
@@ -391,6 +399,7 @@ class quiz_overview_report extends quiz_attempts_report {
$quba = null;
$transaction = null;
gc_collect_cycles();
return $messages;
}
/**
@@ -555,6 +564,7 @@ class quiz_overview_report extends quiz_attempts_report {
*/
protected function regrade_batch_of_attempts($quiz, array $attempts,
bool $dryrun, \core\dml\sql_join $groupstudentsjoins) {
global $OUTPUT;
$this->clear_regrade_table($quiz, $groupstudentsjoins);
$progressbar = new progress_bar('quiz_overview_regrade', 500, true);
@@ -572,7 +582,17 @@ class quiz_overview_report extends quiz_attempts_report {
}
$progressbar->update($a['done'], $a['count'],
get_string('regradingattemptxofywithdetails', 'quiz_overview', $a));
$this->regrade_attempt($attempt, $dryrun, $attempt->regradeonlyslots);
$messages = $this->regrade_attempt($attempt, $dryrun, $attempt->regradeonlyslots);
if ($messages) {
$items = [];
foreach ($messages as $slot => $message) {
$items[] = get_string('regradingattemptissue', 'quiz_overview',
['slot' => $slot, 'reason' => $message]);
}
echo $OUTPUT->notification(
html_writer::tag('p', get_string('regradingattemptxofyproblem', 'quiz_overview', $a)) .
html_writer::alist($items), \core\output\notification::NOTIFY_WARNING);
}
}
$progressbar->update($a['done'], $a['count'],
get_string('regradedsuccessfullyxofy', 'quiz_overview', $a));
@@ -52,8 +52,8 @@ Feature: Regrading quiz attempts using the Grades report
# Also, nothing has changed in the quiz, so the regrade won't alter any scores,
# but this is still a useful test that the regrade process completes without errors.
Then I should see "Quiz for testing regrading"
And I should see "Successfully regraded (2/2)"
And I should see "Regrade completed successfully"
And I should see "Finished regrading (2/2)"
And I should see "Regrade completed"
And I press "Continue"
# These next tests just serve to check we got back to the report.
@@ -67,8 +67,8 @@ Feature: Regrading quiz attempts using the Grades report
And I press "Regrade selected attempts"
Then I should see "Quiz for testing regrading"
And I should see "Successfully regraded (1/1)"
And I should see "Regrade completed successfully"
And I should see "Finished regrading (1/1)"
And I should see "Regrade completed"
And I press "Continue"
# These next tests just serve to check we got back to the report.
@@ -88,8 +88,8 @@ Feature: Regrading quiz attempts using the Grades report
# Note, the order is not defined, so we can only check part of the message.
Then I should see "Quiz for testing regrading"
And I should see "Successfully regraded (2/2)"
And I should see "Regrade completed successfully"
And I should see "Finished regrading (2/2)"
And I should see "Regrade completed"
And I press "Continue"
And "Student One" row "Regrade" column of "attempts" table should not contain "Needed"
@@ -100,8 +100,8 @@ Feature: Regrading quiz attempts using the Grades report
And "Student TwoReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "90.00/75.00"
And I press "Regrade attempts marked as needing regrading (1)"
And I should see "Quiz for testing regrading"
And I should see "Successfully regraded (1/1)"
And I should see "Regrade completed successfully"
And I should see "Finished regrading (1/1)"
And I should see "Regrade completed"
And I press "Continue"
# These next tests just serve to check we got back to the report.
@@ -117,8 +117,8 @@ Feature: Regrading quiz attempts using the Grades report
And I navigate to "Results" in current page administration
When I press "Dry run a full regrade"
Then I should see "Quiz for testing regrading"
And I should see "Successfully regraded (2/2)"
And I should see "Regrade completed successfully"
And I should see "Finished regrading (2/2)"
And I should see "Regrade completed"
And I press "Continue"
And I should see "Quiz for testing regrading"
And I should see "Overall number of students achieving grade ranges"
@@ -132,12 +132,12 @@ Feature: Regrading quiz attempts using the Grades report
And I click on "v2 (latest)" "option"
And I navigate to "Results" in current page administration
And I press "Dry run a full regrade"
And I should see "Regrade completed successfully"
And I should see "Regrade completed"
And I press "Continue"
And "student1@example.com" row "Regrade" column of "attempts" table should contain "Needed"
And "Correct" "icon" should appear before "50.00/0.00" "text"
And I press "Regrade all"
And I should see "Regrade completed successfully"
And I should see "Regrade completed"
And I press "Continue"
Then "student1@example.com" row "Regrade" column of "attempts" table should contain "Done"
And "Student OneReview attempt" row "Q. 1/50.00Sort by Q. 1/50.00 Ascending" column of "attempts" table should contain "50.00/0.00"
@@ -177,8 +177,8 @@ Feature: Regrading quiz attempts using the Grades report
And I click on "Always latest" "option"
And I navigate to "Results" in current page administration
And I press "Regrade all"
And I should see "Successfully regraded (1/1)"
And I should see "Regrade completed successfully"
And I should see "Finished regrading (1/1)"
And I should see "Regrade completed"
And I press "Continue"
Then "student3@example.com" row "Q. 1/50.00Sort by Q. 1/50.00 Ascending" column of "attempts" table should contain "50.00/0.00"
And "Incorrect" "icon" should appear before "50.00/0.00" "text"
@@ -218,8 +218,8 @@ Feature: Regrading quiz attempts using the Grades report
And "student3@example.com" row "Q. 1/100.00Sort by Q. 1/100.00 Ascending" column of "attempts" table should contain "100.00"
And "Correct" "icon" should be visible
And I press "Regrade all"
And I should see "Successfully regraded (1/1)"
And I should see "Regrade completed successfully"
And I should see "Finished regrading (1/1)"
And I should see "Regrade completed"
And I press "Continue"
Then "student3@example.com" row "Q. 1/100.00Sort by Q. 1/100.00 Ascending" column of "attempts" table should contain "100.00/0.00"
And "Incorrect" "icon" should be visible
@@ -0,0 +1,56 @@
@mod @mod_quiz @quiz @quiz_overview @javascript
Feature: Quiz regrade when not possible
In order avoid errors
As a teacher
I need the system to prevent impossible regrade scenarios
Background:
Given the following "users" exist:
| username | firstname | lastname |
| teacher | Mark | Allwright |
| student | Student | One |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher | C1 | editingteacher |
| student | C1 | student |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Quiz for testing regrading | C1 | quiz1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Activity module | quiz1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | template | name |
| Test questions | multichoice | one_of_four | MC |
And quiz "Quiz for testing regrading" contains the following questions:
| question | page | maxmark |
| MC | 1 | 10.0 |
And user "student" has attempted "Quiz for testing regrading" with responses:
| slot | response |
| 1 | B |
Scenario: Try a regrade after the question has been edited to have a different number of choices
# Edit the question so that V2 has the fourth choice removed.
Given I am on the "MC" "core_question > edit" page logged in as teacher
And I set the following fields to these values:
| Choice 4 | |
| id_feedback_3 | |
And I press "id_submitbutton"
# Try a regrade, and verify what happened is reported.
When I am on the "Quiz for testing regrading" "mod_quiz > grades report" page
And I press "Regrade all"
Then I should see "Quiz for testing regrading"
And I should see "The following questions could not be regraded in attempt 1 by Student One"
And I should see "Slot 1: The number of choices in the question has changed."
And I should see "Finished regrading (1/1)"
And I should see "Regrade completed"
And I press "Continue"
# These next tests just serve to check we got back to the report.
And I should see "Quiz for testing regrading"
And I should see "Overall number of students achieving grade ranges"
+8
View File
@@ -1,5 +1,13 @@
This files describes API changes in the quiz code.
=== 4.0.2, 4.1 ===
* No external code should be calling quiz_overview_report::regrade_attempt because it is an
internal method of the quiz_overview plugin. But if you are incorrectly using it, be aware
that the API changed slightly. It now returns an array listing any questions which could
not be regraded.
=== 4.0 ===
* The following API methods have a new parameter, $studentisonline, to define whether the student is currently interacting:
+33 -1
View File
@@ -1396,6 +1396,16 @@ class question_attempt {
$this->process_action(array('-finish' => 1), $timestamp, $userid);
}
/**
* Verify if this question_attempt in can be regraded with that other question version.
*
* @param question_definition $otherversion a different version of the question to use in the regrade.
* @return string|null null if the regrade can proceed, else a reason why not.
*/
public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string {
return $this->get_question(false)->validate_can_regrade_with_other_version($otherversion);
}
/**
* Perform a regrade. This replays all the actions from $oldqa into this
* attempt.
@@ -1412,7 +1422,8 @@ class question_attempt {
if ($first) {
// First step of the attempt.
$first = false;
$this->start($oldqa->behaviour, $oldqa->get_variant(), $step->get_all_data(),
$this->start($oldqa->behaviour, $oldqa->get_variant(),
$this->get_attempt_state_data_to_regrade_with_version($step, $oldqa->get_question()),
$step->get_timecreated(), $step->get_user_id(), $step->get_id());
} else if ($step->has_behaviour_var('finish') && count($step->get_submitted_data()) > 1) {
@@ -1446,6 +1457,27 @@ class question_attempt {
$this->set_flagged($oldqa->is_flagged());
}
/**
* Helper used by regrading.
*
* Get the data from the first step of the old attempt and, if necessary,
* update it to be suitable for use with the other version of the question.
*
* @param question_attempt_step $oldstep First step at an attempt at $otherversion of this question.
* @param question_definition $otherversion Another version of the question being attempted.
* @return array updated data required to restart an attempt with the current version of this question.
*/
protected function get_attempt_state_data_to_regrade_with_version(question_attempt_step $oldstep,
question_definition $otherversion): array {
if ($this->get_question(false) === $otherversion) {
return $oldstep->get_all_data();
} else {
$attemptstatedata = $this->get_question(false)->update_attempt_state_data_for_new_version(
$oldstep, $otherversion);
return array_merge($attemptstatedata, $oldstep->get_behaviour_data());
}
}
/**
* Change the max mark for this question_attempt.
* @param float $maxmark the new max mark.
+12
View File
@@ -875,9 +875,21 @@ class question_usage_by_activity {
$this->observer->notify_attempt_modified($qa);
}
/**
* Verify if the question_attempt in the given slot can be regraded with that other question version.
*
* @param int $slot the number used to identify this question within this usage.
* @param question_definition $otherversion a different version of the question to use in the regrade.
* @return string|null null if the regrade can proceed, else a reason why not.
*/
public function validate_can_regrade_with_other_version(int $slot, question_definition $otherversion): ?string {
return $this->get_question_attempt($slot)->validate_can_regrade_with_other_version($otherversion);
}
/**
* Regrade a question in this usage. This replays the sequence of submitted
* actions to recompute the outcomes.
*
* @param int $slot the number used to identify this question within this usage.
* @param bool $finished whether the question attempt should be forced to be finished
* after the regrade, or whether it may still be in progress (default false).
@@ -28,6 +28,7 @@ $string['answer'] = 'Answer';
$string['bgimage'] = 'Background image';
$string['blank'] = 'blank';
$string['correctansweris'] = 'The correct answer is: {$a}';
$string['deletedchoice'] = '[Deleted choice]';
$string['draggableimage'] = 'Draggable image';
$string['draggableitem'] = 'Draggable item';
$string['draggableitems'] = 'Draggable items';
@@ -75,4 +76,3 @@ $string['summarisechoiceno'] = 'Item {$a}';
$string['summariseplaceno'] = 'Drop zone {$a}';
$string['xleft'] = 'Left';
$string['ytop'] = 'Top';
$string['deletedchoice'] = '[Deleted choice]';
@@ -45,6 +45,8 @@ $string['privacy:metadata'] = 'Select missing words question type plugin allows
$string['privacy:preference:defaultmark'] = 'The default mark set for a given question.';
$string['privacy:preference:penalty'] = 'The penalty for each incorrect try when questions are run using the \'Interactive with multiple tries\' or \'Adaptive mode\' behaviour.';
$string['privacy:preference:shuffleanswers'] = 'Whether the answers should be automatically shuffled.';
$string['regradeissuenumchoiceschanged'] = 'The number of choices in group {$a} changed.';
$string['regradeissuenumgroupsschanged'] = 'The number of groups of choices has changed.';
$string['shuffle'] = 'Shuffle';
$string['tagsnotallowed'] = '{$a->tag} is not allowed. (Only {$a->allowed} are permitted.)';
$string['tagsnotallowedatall'] = '{$a->tag} is not allowed. (No HTML is allowed here.)';
+19
View File
@@ -112,6 +112,25 @@ abstract class qtype_gapselect_question_base extends question_graded_automatical
}
}
public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string {
$basemessage = parent::validate_can_regrade_with_other_version($otherversion);
if ($basemessage) {
return $basemessage;
}
if (count($this->choices) != count($otherversion->choices)) {
return get_string('regradeissuenumgroupsschanged', 'qtype_gapselect');
}
foreach ($this->choices as $group => $choices) {
if (count($this->choices[$group]) != count($otherversion->choices[$group])) {
return get_string('regradeissuenumchoiceschanged', 'qtype_gapselect', $group);
}
}
return null;
}
public function get_question_summary() {
$question = $this->html_to_text($this->questiontext, $this->questiontextformat);
$groups = array();
@@ -34,6 +34,8 @@ require_once($CFG->dirroot . '/question/type/gapselect/tests/helper.php');
* @package qtype_gapselect
* @copyright 2012 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \qtype_gapselect_question_base
* @covers \qtype_gapselect_question
*/
class question_test extends \basic_testcase {
@@ -258,4 +260,32 @@ class question_test extends \basic_testcase {
$options = $question->get_question_definition_for_external_rendering($qa, $displayoptions);
$this->assertEquals(1, $options['shufflechoices']);
}
public function test_validate_can_regrade_with_other_version_ok() {
$question = \test_question_maker::make_question('gapselect');
$newquestion = clone($question);
$this->assertNull($newquestion->validate_can_regrade_with_other_version($question));
}
public function test_validate_can_regrade_with_other_version_bad_groups() {
$question = \test_question_maker::make_question('gapselect');
$newquestion = clone($question);
unset($newquestion->choices[3]);
$this->assertEquals(get_string('regradeissuenumgroupsschanged', 'qtype_gapselect'),
$newquestion->validate_can_regrade_with_other_version($question));
}
public function test_validate_can_regrade_with_other_version_bad_choices() {
$question = \test_question_maker::make_question('gapselect');
$newquestion = clone($question);
unset($newquestion->choices[2][2]);
$this->assertEquals(get_string('regradeissuenumchoiceschanged', 'qtype_gapselect', 2),
$newquestion->validate_can_regrade_with_other_version($question));
}
}
+4 -2
View File
@@ -33,8 +33,6 @@ $string['nomatchinganswer'] = 'You must specify an answer matching the question
$string['nomatchinganswerforq'] = 'You must specify an answer for this question.';
$string['notenoughqsandas'] = 'You must supply at least {$a->q} questions and {$a->a} answers.';
$string['notenoughquestions'] = 'You must supply at least {$a} question and answer pairs.';
$string['shuffle'] = 'Shuffle';
$string['shuffle_help'] = 'If enabled, the order of the questions is randomly shuffled for each attempt, provided that "Shuffle within questions" in the activity settings is also enabled. In a matching question, only the questions are affected by this parameter. The answer choices will always be shuffled.';
$string['pleaseananswerallparts'] = 'Please answer all parts of the question.';
$string['pluginname'] = 'Matching';
$string['pluginname_help'] = 'Matching questions require the respondent to correctly match a list of names or statements (questions) to another list of names or statements (answers).';
@@ -46,3 +44,7 @@ $string['privacy:metadata'] = 'Matching question type plugin allows question aut
$string['privacy:preference:defaultmark'] = 'The default mark set for a given question.';
$string['privacy:preference:penalty'] = 'The penalty for each incorrect try when questions are run using the \'Interactive with multiple tries\' or \'Adaptive mode\' behaviour.';
$string['privacy:preference:shuffleanswers'] = 'Whether the answers should be automatically shuffled.';
$string['regradeissuenumchoiceschanged'] = 'The number of choices has changed.';
$string['regradeissuenumstemschanged'] = 'The number of sub-questions has changed.';
$string['shuffle'] = 'Shuffle';
$string['shuffle_help'] = 'If enabled, the order of the questions is randomly shuffled for each attempt, provided that "Shuffle within questions" in the activity settings is also enabled. In a matching question, only the questions are affected by this parameter. The answer choices will always be shuffled.';
+17
View File
@@ -107,6 +107,23 @@ class qtype_match_question extends question_graded_automatically_with_countback
}
}
public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string {
$basemessage = parent::validate_can_regrade_with_other_version($otherversion);
if ($basemessage) {
return $basemessage;
}
if (count($this->stems) != count($otherversion->stems)) {
return get_string('regradeissuenumstemschanged', 'qtype_match');
}
if (count($this->choices) != count($otherversion->choices)) {
return get_string('regradeissuenumchoiceschanged', 'qtype_match');
}
return null;
}
public function get_question_summary() {
$question = $this->html_to_text($this->questiontext, $this->questiontextformat);
$stems = array();
@@ -33,6 +33,7 @@ require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
* @package qtype_match
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \qtype_match_question
*/
class question_test extends \advanced_testcase {
@@ -244,4 +245,32 @@ class question_test extends \advanced_testcase {
$options = $question->get_question_definition_for_external_rendering($qa, $displayoptions);
$this->assertEquals(1, $options['shufflestems']);
}
public function test_validate_can_regrade_with_other_version_ok() {
$m = \test_question_maker::make_question('match');
$newm = clone($m);
$this->assertNull($newm->validate_can_regrade_with_other_version($m));
}
public function test_validate_can_regrade_with_other_version_bad_stems() {
$m = \test_question_maker::make_question('match');
$newm = clone($m);
unset($newm->stems[4]);
$this->assertEquals(get_string('regradeissuenumstemschanged', 'qtype_match'),
$newm->validate_can_regrade_with_other_version($m));
}
public function test_validate_can_regrade_with_other_version_bad_choices() {
$m = \test_question_maker::make_question('match');
$newm = clone($m);
unset($newm->choices[3]);
$this->assertEquals(get_string('regradeissuenumchoiceschanged', 'qtype_match'),
$newm->validate_can_regrade_with_other_version($m));
}
}
@@ -67,6 +67,7 @@ $string['questionsaveasedited'] = 'The question will be saved as edited';
$string['questiontypechanged'] = 'Question type changed';
$string['questiontypechangedcomment'] = 'At least one question type has been changed.<br />Did you add, delete or move a question?<br />Look ahead.';
$string['questionusedinquiz'] = 'This question is used in {$a->nb_of_quiz} quiz(s), total attempt(s) : {$a->nb_of_attempts} ';
$string['regradeissuenumsubquestionschanged'] = 'The number embedded sub-questions in the question has changed.';
$string['storedqtype'] = 'Stored question type {$a}';
$string['subqresponse'] = 'part {$a->i}: {$a->response}';
$string['unknownquestiontypeofsubquestion'] = 'Unknown question type: {$a->type} of question part # {$a->sub}';
+37
View File
@@ -78,6 +78,43 @@ class qtype_multianswer_question extends question_graded_automatically_with_coun
}
}
public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string {
$basemessage = parent::validate_can_regrade_with_other_version($otherversion);
if ($basemessage) {
return $basemessage;
}
if (count($this->subquestions) != count($otherversion->subquestions)) {
return get_string('regradeissuenumsubquestionschanged', 'qtype_multianswer');
}
foreach ($this->subquestions as $i => $subq) {
$subqmessage = $subq->validate_can_regrade_with_other_version($otherversion->subquestions[$i]);
if ($subqmessage) {
return $subqmessage;
}
}
return null;
}
public function update_attempt_state_data_for_new_version(
question_attempt_step $oldstep, question_definition $oldquestion) {
parent::update_attempt_state_data_for_new_version($oldstep, $oldquestion);
$result = [];
foreach ($this->subquestions as $i => $subq) {
$substep = $this->get_substep($oldstep, $i);
$statedata = $subq->update_attempt_state_data_for_new_version(
$substep, $oldquestion->subquestions[$i]);
foreach ($statedata as $name => $value) {
$result[$substep->add_prefix($name)] = $value;
}
}
return $result;
}
public function get_question_summary() {
$summary = $this->html_to_text($this->questiontext, $this->questiontextformat);
foreach ($this->subquestions as $i => $subq) {
@@ -32,6 +32,7 @@ require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
* @package qtype_multianswer
* @copyright 2011 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \qtype_multianswer_question
*/
class question_test extends \advanced_testcase {
public function test_get_expected_data() {
@@ -252,4 +253,100 @@ class question_test extends \advanced_testcase {
$options = $question->get_question_definition_for_external_rendering($qa, $displayoptions);
$this->assertNull($options);
}
/**
* Helper method to make a simulated second version of the standard multianswer test question.
*
* The key think is that all the answer ids are changed (increased by 20).
*
* @param \qtype_multianswer_question $question
* @return \qtype_multianswer_question
*/
protected function make_second_version(
\qtype_multianswer_question $question): \qtype_multianswer_question {
$newquestion = fullclone($question);
$newquestion->subquestions[1]->answers = [
36 => new \question_answer(16, 'Apple', 0.3333333,
'Good', FORMAT_HTML),
37 => new \question_answer(17, 'Burger', -0.5,
'', FORMAT_HTML),
38 => new \question_answer(18, 'Hot dog', -0.5,
'Not a fruit', FORMAT_HTML),
39 => new \question_answer(19, 'Pizza', -0.5,
'', FORMAT_HTML),
40 => new \question_answer(20, 'Orange', 0.3333333,
'Correct', FORMAT_HTML),
41 => new \question_answer(21, 'Banana', 0.3333333,
'', FORMAT_HTML),
];
$newquestion->subquestions[2]->answers = [
42 => new \question_answer(22, 'Raddish', 0.5,
'Good', FORMAT_HTML),
43 => new \question_answer(23, 'Chocolate', -0.5,
'', FORMAT_HTML),
44 => new \question_answer(24, 'Biscuit', -0.5,
'Not a vegetable', FORMAT_HTML),
45 => new \question_answer(25, 'Cheese', -0.5,
'', FORMAT_HTML),
46 => new \question_answer(26, 'Carrot', 0.5,
'Correct', FORMAT_HTML),
];
return $newquestion;
}
public function test_validate_can_regrade_with_other_version_ok() {
/** @var \qtype_multianswer_question $question */
$question = \test_question_maker::make_question('multianswer', 'multiple');
$newquestion = $this->make_second_version($question);
$this->assertNull($newquestion->validate_can_regrade_with_other_version($question));
}
public function test_validate_can_regrade_with_other_version_wrong_subquestions() {
/** @var \qtype_multianswer_question $question */
$question = \test_question_maker::make_question('multianswer', 'multiple');
$newquestion = $this->make_second_version($question);
unset($newquestion->subquestions[2]);
$this->assertEquals(
get_string('regradeissuenumsubquestionschanged', 'qtype_multianswer'),
$newquestion->validate_can_regrade_with_other_version($question));
}
public function test_validate_can_regrade_with_other_version_one_wrong_subquestion() {
/** @var \qtype_multianswer_question $question */
$question = \test_question_maker::make_question('multianswer', 'multiple');
$newquestion = $this->make_second_version($question);
unset($newquestion->subquestions[1]->answers[41]);
$this->assertEquals(
get_string('regradeissuenumchoiceschanged', 'qtype_multichoice'),
$newquestion->validate_can_regrade_with_other_version($question));
}
public function test_update_attempt_state_date_from_old_version_ok() {
/** @var \qtype_multianswer_question $question */
$question = \test_question_maker::make_question('multianswer', 'multiple');
$newquestion = $this->make_second_version($question);
$oldstep = new question_attempt_step();
$oldstep->set_qt_var('_sub1_order', '16,17,18,19,20,21');
$oldstep->set_qt_var('_sub2_order', '22,23,24,25,26');
$expected = [
'_sub1_order' => '36,37,38,39,40,41',
'_sub2_order' => '42,43,44,45,46',
];
$this->assertEquals($expected,
$newquestion->update_attempt_state_data_for_new_version($oldstep, $question));
}
}
@@ -72,6 +72,7 @@ $string['privacy:preference:single'] = 'Whether the answer is single with radio
$string['privacy:preference:shuffleanswers'] = 'Whether the answers should be automatically shuffled.';
$string['privacy:preference:answernumbering'] = 'Which numbering style should be used (\'1, 2, 3, ...\', \'a, b, c, ...\' etc.).';
$string['privacy:preference:showstandardinstruction'] = 'Whether showing standard instruction.';
$string['regradeissuenumchoiceschanged'] = 'The number of choices in the question has changed.';
$string['selectmulti'] = 'Select one or more:';
$string['selectone'] = 'Select one:';
$string['shuffleanswers'] = 'Shuffle the choices?';
+55 -3
View File
@@ -89,6 +89,34 @@ abstract class qtype_multichoice_base extends question_graded_automatically {
}
}
public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string {
$basemessage = parent::validate_can_regrade_with_other_version($otherversion);
if ($basemessage) {
return $basemessage;
}
if (count($this->answers) != count($otherversion->answers)) {
return get_string('regradeissuenumchoiceschanged', 'qtype_multichoice');
}
return null;
}
public function update_attempt_state_data_for_new_version(
question_attempt_step $oldstep, question_definition $otherversion) {
parent::update_attempt_state_data_for_new_version($oldstep, $otherversion);
$mapping = array_combine(array_keys($otherversion->answers), array_keys($this->answers));
$oldorder = explode(',', $oldstep->get_qt_var('_order'));
$neworder = [];
foreach ($oldorder as $oldid) {
$neworder[] = $mapping[$oldid] ?? $oldid;
}
return ['_order' => implode(',', $neworder)];
}
public function get_question_summary() {
$question = $this->html_to_text($this->questiontext, $this->questiontextformat);
$choices = array();
@@ -203,9 +231,19 @@ class qtype_multichoice_single_question extends qtype_multichoice_base {
if (!$this->is_complete_response($response)) {
return null;
}
$ansid = $this->order[$response['answer']];
return $this->html_to_text($this->answers[$ansid]->answer,
$this->answers[$ansid]->answerformat);
$answerid = $this->order[$response['answer']];
return $this->html_to_text($this->answers[$answerid]->answer,
$this->answers[$answerid]->answerformat);
}
public function un_summarise_response(string $summary) {
foreach ($this->order as $key => $answerid) {
if ($summary === $this->html_to_text($this->answers[$answerid]->answer,
$this->answers[$answerid]->answerformat)) {
return ['answer' => $key];
}
}
return [];
}
public function classify_response(array $response) {
@@ -370,6 +408,20 @@ class qtype_multichoice_multi_question extends qtype_multichoice_base {
return implode('; ', $selectedchoices);
}
public function un_summarise_response(string $summary) {
// This implementation is not perfect. It will fail if an answer contains '; ',
// but this method is only for testing, so it is good enough.
$selectedchoices = explode('; ', $summary);
$response = [];
foreach ($this->order as $key => $answerid) {
if (in_array($this->html_to_text($this->answers[$answerid]->answer,
$this->answers[$answerid]->answerformat), $selectedchoices)) {
$response[$this->field($key)] = '1';
}
}
return $response;
}
public function classify_response(array $response) {
$selectedchoices = array();
foreach ($this->order as $key => $ansid) {
@@ -33,6 +33,7 @@ require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \qtype_multichoice_multi_question
*/
class qtype_multichoice_multi_question_test extends advanced_testcase {
@@ -127,8 +128,7 @@ class qtype_multichoice_multi_question_test extends advanced_testcase {
$mc->shuffleanswers = false;
$mc->start_attempt(new question_attempt_step(), 1);
$summary = $mc->summarise_response($mc->prepare_simulated_post_data(array('B' => 1, 'C' => 1)),
test_question_maker::get_a_qa($mc));
$summary = $mc->summarise_response($mc->prepare_simulated_post_data(['B' => 1, 'C' => 1]));
$this->assertEquals('B; C', $summary);
}
@@ -138,12 +138,21 @@ class qtype_multichoice_multi_question_test extends advanced_testcase {
$mc->shuffleanswers = false;
$mc->start_attempt(new question_attempt_step(), 1);
$summary = $mc->summarise_response($mc->prepare_simulated_post_data(array('clearchoice' => -1)),
test_question_maker::get_a_qa($mc));
$summary = $mc->summarise_response($mc->prepare_simulated_post_data(['clearchoice' => -1]));
$this->assertNull($summary);
}
public function test_un_summarise_response() {
$mc = test_question_maker::make_a_multichoice_multi_question();
$mc->shuffleanswers = false;
$mc->start_attempt(new question_attempt_step(), 1);
$this->assertEquals(['choice1' => '1', 'choice2' => '1'], $mc->un_summarise_response('B; C'));
$this->assertEquals([], $mc->un_summarise_response(''));
}
public function test_classify_response() {
$mc = test_question_maker::make_a_multichoice_multi_question();
$mc->start_attempt(new question_attempt_step(), 1);
@@ -33,6 +33,7 @@ require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \qtype_multichoice_single_question
*/
class qtype_multichoice_single_question_test extends advanced_testcase {
@@ -159,8 +160,18 @@ class qtype_multichoice_single_question_test extends advanced_testcase {
$this->assertEquals('A', $summary);
$this->assertNull($mc->summarise_response(array(), test_question_maker::get_a_qa($mc)));
$this->assertNull($mc->summarise_response(array('answer' => '-1'), test_question_maker::get_a_qa($mc)));
$this->assertNull($mc->summarise_response([]));
$this->assertNull($mc->summarise_response(['answer' => '-1']));
}
public function test_un_summarise_response() {
$mc = test_question_maker::make_a_multichoice_single_question();
$mc->shuffleanswers = false;
$mc->start_attempt(new question_attempt_step(), 1);
$this->assertEquals(['answer' => '1'], $mc->un_summarise_response('B'));
$this->assertEquals([], $mc->un_summarise_response(''));
}
public function test_classify_response() {
@@ -209,4 +220,61 @@ class qtype_multichoice_single_question_test extends advanced_testcase {
$reconstucted = $mc->prepare_simulated_post_data($simulated);
$this->assertEquals($originalresponse, $reconstucted);
}
public function test_validate_can_regrade_with_other_version_bad() {
$mc = test_question_maker::make_a_multichoice_single_question();
$newmc = clone($mc);
$newmc->answers = array(
23 => new question_answer(13, 'A', 1, 'A is right', FORMAT_HTML),
24 => new question_answer(14, 'B', -0.3333333, 'B is wrong', FORMAT_HTML),
);
$this->assertEquals(get_string('regradeissuenumchoiceschanged', 'qtype_multichoice'),
$newmc->validate_can_regrade_with_other_version($mc));
}
public function test_validate_can_regrade_with_other_version_ok() {
$mc = test_question_maker::make_a_multichoice_single_question();
$newmc = clone($mc);
$newmc->answers = array(
23 => new question_answer(13, 'A', 1, 'A is right', FORMAT_HTML),
24 => new question_answer(14, 'B', -0.3333333, 'B is wrong', FORMAT_HTML),
25 => new question_answer(15, 'C', -0.3333333, 'C is wrong', FORMAT_HTML),
);
$this->assertNull($newmc->validate_can_regrade_with_other_version($mc));
}
public function test_update_attempt_state_date_from_old_version_bad() {
$mc = test_question_maker::make_a_multichoice_single_question();
$newmc = clone($mc);
$newmc->answers = array(
23 => new question_answer(13, 'A', 1, 'A is right', FORMAT_HTML),
24 => new question_answer(14, 'B', -0.3333333, 'B is wrong', FORMAT_HTML),
);
$oldstep = new question_attempt_step();
$oldstep->set_qt_var('_order', '14,13,15');
$this->expectExceptionMessage(get_string('regradeissuenumchoiceschanged', 'qtype_multichoice'));
$newmc->update_attempt_state_data_for_new_version($oldstep, $mc);
}
public function test_update_attempt_state_date_from_old_version_ok() {
$mc = test_question_maker::make_a_multichoice_single_question();
$newmc = clone($mc);
$newmc->answers = array(
23 => new question_answer(13, 'A', 1, 'A is right', FORMAT_HTML),
24 => new question_answer(14, 'B', -0.3333333, 'B is wrong', FORMAT_HTML),
25 => new question_answer(15, 'C', -0.3333333, 'C is wrong', FORMAT_HTML),
);
$oldstep = new question_attempt_step();
$oldstep->set_qt_var('_order', '14,13,15');
$this->assertEquals(['_order' => '24,23,25'],
$newmc->update_attempt_state_data_for_new_version($oldstep, $mc));
}
}
@@ -69,6 +69,27 @@ class walkthrough_test extends \qbehaviour_walkthrough_test_base {
$this->get_contains_correct_expectation(),
new \question_pattern_expectation('/class="r0 correct"/'),
new \question_pattern_expectation('/class="r1"/'));
// Regrade with a new version of the question.
$newmc = \test_question_maker::make_a_multichoice_single_question();
$newmc->answers = [
23 => $newmc->answers[13],
24 => $newmc->answers[14],
25 => $newmc->answers[15],
];
$newmc->answers[23]->fraction = 0.5;
$newmc->answers[23]->feedback = 'A is now only partially right';
$newmc->answers[24]->fraction = 1;
$newmc->answers[24]->answer = 'B is the new right answer';
$this->quba->regrade_question($this->slot, true, null, $newmc);
// Verify.
$this->check_current_mark(1.5);
$this->render();
$this->assertStringContainsString('A is now only partially right', $this->currentoutput);
$this->assertStringContainsString('B is the new right answer', $this->currentoutput);
$this->assertStringNotContainsString(
get_string('deletedchoice', 'qtype_multichoice'), $this->currentoutput);
}
public function test_deferredfeedback_feedback_multichoice_single_showstandardunstruction_yes() {
+47
View File
@@ -211,6 +211,53 @@ abstract class question_definition {
public function apply_attempt_state(question_attempt_step $step) {
}
/**
* Verify if an attempt at this question can be re-graded using the other question version.
*
* To put it another way, will {@see update_attempt_state_date_from_old_version()} be able to work?
*
* It is expected that this relationship is symmetrical, so if you can regrade from V1 to V3, then
* you can change back from V3 to V1.
*
* @param question_definition $otherversion a different version of the question to use in the regrade.
* @return string|null null if the regrade can proceed, else a reason why not.
*/
public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string {
if (get_class($otherversion) !== get_class($this)) {
return get_string('cannotregradedifferentqtype', 'question');
}
return null;
}
/**
* Update the data representing the initial state of an attempt another version of this question, to allow for the changes.
*
* What is required is probably most easily understood using an example. Think about multiple choice questions.
* The first step has a variable '_order' which is a comma-separated list of question_answer ids.
* A different version of the question will have different question_answers with different ids. However, the list of
* choices should be similar, and so we need to shuffle the new list of ids in the same way that the old one was.
*
* This method should only be called if {@see validate_can_regrade_with_other_version()} did not
* flag up a potential problem. So, this method will throw a {@see coding_exception} if it is not
* possible to work out a return value.
*
* @param question_attempt_step $oldstep the first step of a {@see question_attempt} at $oldquestion.
* @param question_definition $oldquestion the previous version of the question, which $oldstate comes from.
* @return array the submit data which can be passed to {@see apply_attempt_state} to start
* an attempt at this version of this question, corresponding to the attempt at the old question.
* @throws coding_exception if this can't be done.
*/
public function update_attempt_state_data_for_new_version(
question_attempt_step $oldstep, question_definition $oldquestion) {
$message = $this->validate_can_regrade_with_other_version($oldquestion);
if ($message) {
throw new coding_exception($message);
}
return $oldstep->get_qt_data();
}
/**
* Generate a brief, plain-text, summary of this question. This is used by
* various reports. This should show the particular variant of the question
+21 -5
View File
@@ -1,17 +1,32 @@
This files describes API changes for question type plugins.
=== 4.0.2, 4.1 ===
* There was one issue caused by the changes in Moodle 4.0 which requires changes in question types,
and that is regrading. There are two new methods which some question types will need to implement,
- validate_can_regrade_with_other_version
- update_attempt_state_data_for_new_version
these methods are introduced in this commit, and there are details PHPdoc comments about what they
must do. Then the immediately following commits implement them in the core question types where
they are required.
Generally, you will need to implement one or both of these if you question type does something
significant in the apply_attempt_state method. If you have not implemented that method, then
almost certainly you don't need to worry about this.
=== 4.0 ===
1) The major question bank changes should not affect most basic question type plugins.
The navigation changes may affect Behat tests. If you encounter this,
the best way to fix it is to use the new navigation steps in MDL-74130.
* The major question bank changes should not affect most basic question type plugins.
The navigation changes may affect Behat tests. If you encounter this,
the best way to fix it is to use the new navigation steps in MDL-74130.
2) The qualification 'most' is because some question types do more complex things, which
will require changes related to question versionning. Some examples that come to mind:
* The qualification 'most' is because some question types do more complex things, which
will require changes related to question versionning. Some examples that come to mind:
- the way qtype_mulitanswer (or qtype_combined) aggregates several sub-questions into a parent question.
- the way some contrib plugins (e.g. qtype_stack, qtype_pmatch) store additional data (question tests)
linked to questions. That relationship will need to be updated.
=== 3.11 ===
* Introducing the following \question_type base class methods to save/fetch the last form values
@@ -42,6 +57,7 @@ This files describes API changes for question type plugins.
use it in a question type at
https://github.com/moodleou/moodle-qtype_pmatch/commit/2aefa8b5dcc7bab768f4707a4ffb7befcf4c2540.
=== 3.8, 3.7.3, 3.6.7 ===
* Coming up in Moodle 3.8 are some changes to the question bank UI. These will break any