2 // This file is part of Moodle - http://moodle.org/
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
18 * Ordering question definition classes.
20 * @package qtype_ordering
22 * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com)
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26 // Prevent direct access to this script.
29 * Represents an ordering question.
31 * @copyright 2013 Gordon Bateson (gordon.bateson@gmail.com)
32 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34 class qtype_ordering_question
extends question_graded_automatically
{
36 /** Select all answers */
38 /** Select random set of answers */
39 const SELECT_RANDOM
= 1;
40 /** Select contiguous subset of answers */
41 const SELECT_CONTIGUOUS
= 2;
43 /** Show answers in vertical list */
44 const LAYOUT_VERTICAL
= 0;
45 /** Show answers in one horizontal line */
46 const LAYOUT_HORIZONTAL
= 1;
48 /** Default value for numberingstyle */
49 const NUMBERING_STYLE_DEFAULT
= 'none';
51 /** @var int Zero grade on any error */
52 const GRADING_ALL_OR_NOTHING
= -1;
53 /** @var int Counts items, placed into right absolute place */
54 const GRADING_ABSOLUTE_POSITION
= 0;
55 /** @var int Every sequential pair in right order is graded (last pair is excluded) */
56 const GRADING_RELATIVE_NEXT_EXCLUDE_LAST
= 1;
57 /** @var int Every sequential pair in right order is graded (last pair is included) */
58 const GRADING_RELATIVE_NEXT_INCLUDE_LAST
= 2;
59 /** @var int Single answers that are placed before and after each answer is graded if in right order*/
60 const GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT
= 3;
61 /** @var int All answers that are placed before and after each answer is graded if in right order*/
62 const GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT
= 4;
63 /** @var int Only longest ordered subset is graded */
64 const GRADING_LONGEST_ORDERED_SUBSET
= 5;
65 /** @var int Only longest ordered and contiguous subset is graded */
66 const GRADING_LONGEST_CONTIGUOUS_SUBSET
= 6;
67 /** @var int Items are graded relative to their position in the correct answer */
68 const GRADING_RELATIVE_TO_CORRECT
= 7;
70 // Fields from "qtype_ordering_options" table.
72 public $correctfeedback;
74 public $correctfeedbackformat;
76 public $incorrectfeedback;
78 public $incorrectfeedbackformat;
80 public $partiallycorrectfeedback;
82 public $partiallycorrectfeedbackformat;
84 /** @var array Records from "question_answers" table */
87 /** @var stdClass Records from "qtype_ordering_options" table */
90 /** @var array of answerids in correct order */
91 public $correctresponse;
93 /** @var array contatining current order of answerids */
94 public $currentresponse;
96 /** @var array of scored for every item */
97 protected $itemscores = [];
100 * Start a new attempt at this question, storing any information that will
101 * be needed later in the step.
103 * This is where the question can do any initialisation required on a
104 * per-attempt basis. For example, this is where the multiple choice
105 * question type randomly shuffles the choices (if that option is set).
107 * Any information about how the question has been set up for this attempt
108 * should be stored in the $step, by calling $step->set_qt_var(...).
110 * @param question_attempt_step $step The first step of the {@see question_attempt}
111 * being started. Can be used to store state.
112 * @param int $variant which variant of this question to start. Will be between
113 * 1 and {@see get_num_variants()} inclusive.
115 public function start_attempt(question_attempt_step
$step, $variant) {
116 $countanswers = count($this->answers
);
118 // Sanitize "selecttype".
119 $selecttype = $this->options
->selecttype
;
120 $selecttype = max(0, $selecttype);
121 $selecttype = min(2, $selecttype);
123 // Sanitize "selectcount".
124 $selectcount = $this->options
->selectcount
;
125 $selectcount = max(3, $selectcount);
126 $selectcount = min($countanswers, $selectcount);
128 // Ensure consistency between "selecttype" and "selectcount".
130 case ($selecttype == self
::SELECT_ALL
):
131 $selectcount = $countanswers;
133 case ($selectcount == $countanswers):
134 $selecttype = self
::SELECT_ALL
;
138 // Extract answer ids.
139 switch ($selecttype) {
140 case self
::SELECT_ALL
:
141 $answerids = array_keys($this->answers
);
144 case self
::SELECT_RANDOM
:
145 $answerids = array_rand($this->answers
, $selectcount);
148 case self
::SELECT_CONTIGUOUS
:
149 $answerids = array_keys($this->answers
);
150 $offset = mt_rand(0, $countanswers - $selectcount);
151 $answerids = array_slice($answerids, $offset, $selectcount);
155 $this->correctresponse
= $answerids;
156 $step->set_qt_var('_correctresponse', implode(',', $this->correctresponse
));
159 $this->currentresponse
= $answerids;
160 $step->set_qt_var('_currentresponse', implode(',', $this->currentresponse
));
164 * When an in-progress {@see question_attempt} is re-loaded from the
165 * database, this method is called so that the question can re-initialise
166 * its internal state as needed by this attempt.
168 * For example, the multiple choice question type needs to set the order
169 * of the choices to the order that was set up when start_attempt was called
170 * originally. All the information required to do this should be in the
171 * $step object, which is the first step of the question_attempt being loaded.
173 * @param question_attempt_step $step The first step of the {@see question_attempt}
176 public function apply_attempt_state(question_attempt_step
$step) {
177 $this->currentresponse
= array_filter(explode(',', $step->get_qt_var('_currentresponse')));
178 $this->correctresponse
= array_filter(explode(',', $step->get_qt_var('_correctresponse')));
181 public function validate_can_regrade_with_other_version(question_definition
$otherversion): ?
string {
182 $basemessage = parent
::validate_can_regrade_with_other_version($otherversion);
187 if (count($this->answers
) != count($otherversion->answers
)) {
188 return get_string('regradeissuenumitemschanged', 'qtype_ordering');
194 public function update_attempt_state_data_for_new_version(
195 question_attempt_step
$oldstep, question_definition
$otherversion) {
196 parent
::update_attempt_state_data_for_new_version($oldstep, $otherversion);
198 $mapping = array_combine(array_keys($otherversion->answers
), array_keys($this->answers
));
200 $oldorder = explode(',', $oldstep->get_qt_var('_currentresponse'));
202 foreach ($oldorder as $oldid) {
203 $neworder[] = $mapping[$oldid] ??
$oldid;
206 $oldcorrect = explode(',', $oldstep->get_qt_var('_correctresponse'));
208 foreach ($oldcorrect as $oldid) {
209 $newcorrect[] = $mapping[$oldid] ??
$oldid;
213 '_currentresponse' => implode(',', $neworder),
214 '_correctresponse' => implode(',', $newcorrect),
219 * What data may be included in the form submission when a student submits
220 * this question in its current state?
222 * This information is used in calls to optional_param. The parameter name
223 * has {@see question_attempt::get_field_prefix()} automatically prepended.
225 * @return array|string variable name => PARAM_... constant, or, as a special case
226 * that should only be used in unavoidable, the constant question_attempt::USE_RAW_DATA
227 * meaning take all the raw submitted data belonging to this question.
229 public function get_expected_data() {
230 $name = $this->get_response_fieldname();
231 return [$name => PARAM_TEXT
];
235 * What data would need to be submitted to get this question correct.
236 * If there is more than one correct answer, this method should just
237 * return one possibility. If it is not possible to compute a correct
238 * response, this method should return null.
240 * @return array|null parameter name => value.
242 public function get_correct_response() {
243 $correctresponse = $this->correctresponse
;
244 foreach ($correctresponse as $position => $answerid) {
245 $answer = $this->answers
[$answerid];
246 $correctresponse[$position] = $answer->md5key
;
248 $name = $this->get_response_fieldname();
249 return [$name => implode(',', $correctresponse)];
253 * Produce a plain text summary of a response.
255 * @param array $response a response, as might be passed to {@see grade_response()}.
256 * @return string a plain text summary of that response, that could be used in reports.
258 public function summarise_response(array $response) {
259 $name = $this->get_response_fieldname();
260 if (array_key_exists($name, $response)) {
261 $items = explode(',', $response[$name]);
263 $items = []; // Shouldn't happen!
266 foreach ($this->answers
as $answer) {
267 $answerids[$answer->md5key
] = $answer->id
;
269 foreach ($items as $i => $item) {
270 if (array_key_exists($item, $answerids)) {
271 $item = $this->answers
[$answerids[$item]];
272 $item = $this->html_to_text($item->answer
, $item->answerformat
);
273 $item = shorten_text($item, 10, true); // Force truncate at 10 chars.
276 $items[$i] = ''; // Shouldn't happen!
279 return implode('; ', array_filter($items));
283 * Categorise the student's response according to the categories defined by
284 * get_possible_responses.
286 * @param array $response a response, as might be passed to {@see grade_response()}.
287 * @return array subpartid => {@see question_classified_response} objects.
288 * returns an empty array if no analysis is possible.
290 public function classify_response(array $response) {
291 $this->update_current_response($response);
292 $fraction = 1 / count($this->correctresponse
);
294 $classifiedresponse = [];
295 foreach ($this->correctresponse
as $position => $answerid) {
296 if (in_array($answerid, $this->currentresponse
)) {
297 $currentposition = array_search($answerid, $this->currentresponse
);
300 $answer = $this->answers
[$answerid];
301 $subqid = question_utils
::to_plain_text($answer->answer
, $answer->answerformat
);
303 // Truncate responses longer than 100 bytes because they cannot be stored in the database.
304 // CAUTION: This will mess up answers which are not unique within the first 100 chars!
306 if (strlen($subqid) > $maxbytes) {
307 // If the truncation point is in the middle of a multi-byte unicode char,
308 // we remove the incomplete part with a preg_match() that is unicode aware.
309 $subqid = substr($subqid, 0, $maxbytes);
310 if (preg_match('/^(.|\n)*/u', '', $subqid, $match)) {
315 $classifiedresponse[$subqid] = new question_classified_response(
316 $currentposition +
1,
317 get_string('positionx', 'qtype_ordering', $currentposition +
1),
318 ($currentposition == $position) * $fraction
322 return $classifiedresponse;
326 * Used by many of the behaviours, to work out whether the student's
327 * response to the question is complete. That is, whether the question attempt
328 * should move to the COMPLETE or INCOMPLETE state.
330 * @param array $response responses, as returned by
331 * {@see question_attempt_step::get_qt_data()}.
332 * @return bool whether this response is a complete answer to this question.
334 public function is_complete_response(array $response) {
339 * Use by many of the behaviours to determine whether the student
340 * has provided enough of an answer for the question to be graded automatically,
341 * or whether it must be considered aborted.
343 * @param array $response responses, as returned by
344 * {@see question_attempt_step::get_qt_data()}.
345 * @return bool whether this response can be graded.
347 public function is_gradable_response(array $response) {
352 * In situations where is_gradable_response() returns false, this method
353 * should generate a description of what the problem is.
355 * @param array $response
356 * @return string the message
358 public function get_validation_error(array $response) {
363 * Use by many of the behaviours to determine whether the student's
364 * response has changed. This is normally used to determine that a new set
365 * of responses can safely be discarded.
367 * @param array $old the responses previously recorded for this question,
368 * as returned by {@see question_attempt_step::get_qt_data()}
369 * @param array $new the new responses, in the same format.
370 * @return bool whether the two sets of responses are the same - that is
371 * whether the new set of responses can safely be discarded.
373 public function is_same_response(array $old, array $new) {
374 $name = $this->get_response_fieldname();
375 return (isset($old[$name]) && isset($new[$name]) && $old[$name] == $new[$name]);
379 * Grade a response to the question, returning a fraction between
380 * get_min_fraction() and get_max_fraction(), and the corresponding {@see question_state}
381 * right, partial or wrong.
383 * @param array $response responses, as returned by
384 * {@see question_attempt_step::get_qt_data()}.
385 * @return array (float, integer) the fraction, and the state.
387 public function grade_response(array $response) {
388 $this->update_current_response($response);
393 $gradingtype = $this->options
->gradingtype
;
394 switch ($gradingtype) {
396 case self
::GRADING_ALL_OR_NOTHING
:
397 case self
::GRADING_ABSOLUTE_POSITION
:
398 $correctresponse = $this->correctresponse
;
399 $currentresponse = $this->currentresponse
;
400 foreach ($correctresponse as $position => $answerid) {
401 if (array_key_exists($position, $currentresponse)) {
402 if ($currentresponse[$position] == $answerid) {
408 if ($gradingtype == self
::GRADING_ALL_OR_NOTHING
&& $countcorrect < $countanswers) {
413 case self
::GRADING_RELATIVE_NEXT_EXCLUDE_LAST
:
414 case self
::GRADING_RELATIVE_NEXT_INCLUDE_LAST
:
415 $lastitem = ($gradingtype == self
::GRADING_RELATIVE_NEXT_INCLUDE_LAST
);
416 $currentresponse = $this->get_next_answerids($this->currentresponse
, $lastitem);
417 $correctresponse = $this->get_next_answerids($this->correctresponse
, $lastitem);
418 foreach ($correctresponse as $thisanswerid => $nextanswerid) {
419 if (array_key_exists($thisanswerid, $currentresponse)) {
420 if ($currentresponse[$thisanswerid] == $nextanswerid) {
428 case self
::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT
:
429 case self
::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT
:
430 $all = ($gradingtype == self
::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT
);
431 $currentresponse = $this->get_previous_and_next_answerids($this->currentresponse
, $all);
432 $correctresponse = $this->get_previous_and_next_answerids($this->correctresponse
, $all);
433 foreach ($correctresponse as $thisanswerid => $answerids) {
434 if (array_key_exists($thisanswerid, $currentresponse)) {
435 $prev = $currentresponse[$thisanswerid]->prev
;
436 $prev = array_intersect($prev, $answerids->prev
);
437 $countcorrect +
= count($prev);
438 $next = $currentresponse[$thisanswerid]->next
;
439 $next = array_intersect($next, $answerids->next
);
440 $countcorrect +
= count($next);
442 $countanswers +
= count($answerids->prev
);
443 $countanswers +
= count($answerids->next
);
447 case self
::GRADING_LONGEST_ORDERED_SUBSET
:
448 case self
::GRADING_LONGEST_CONTIGUOUS_SUBSET
:
449 $contiguous = ($gradingtype == self
::GRADING_LONGEST_CONTIGUOUS_SUBSET
);
450 $subset = $this->get_ordered_subset($contiguous);
451 $countcorrect = count($subset);
452 $countanswers = count($this->currentresponse
);
455 case self
::GRADING_RELATIVE_TO_CORRECT
:
456 $correctresponse = $this->correctresponse
;
457 $currentresponse = $this->currentresponse
;
458 $count = (count($correctresponse) - 1);
459 foreach ($correctresponse as $position => $answerid) {
460 if (in_array($answerid, $currentresponse)) {
461 $currentposition = array_search($answerid, $currentresponse);
462 $currentscore = ($count - abs($position - $currentposition));
463 if ($currentscore > 0) {
464 $countcorrect +
= $currentscore;
467 $countanswers +
= $count;
471 if ($countanswers == 0) {
474 $fraction = ($countcorrect / $countanswers);
478 question_state
::graded_state_for_fraction($fraction),
483 * Checks whether the user has permission to access a particular file.
485 * @param question_attempt $qa the question attempt being displayed.
486 * @param question_display_options $options the options that control display of the question.
487 * @param string $component the name of the component we are serving files for.
488 * @param string $filearea the name of the file area.
489 * @param array $args the remaining bits of the file path.
490 * @param bool $forcedownload whether the user must be forced to download the file.
491 * @return bool true if the user can access this file.
493 public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) {
494 if ($component == 'question') {
495 if ($filearea == 'answer') {
496 $answerid = reset($args); // Value of "itemid" is answer id.
497 return array_key_exists($answerid, $this->answers
);
499 if (in_array($filearea, $this->qtype
->feedbackfields
)) {
500 return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args);
502 if ($filearea == 'hint') {
503 return $this->check_hint_file_access($qa, $options, $args);
506 return parent
::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload);
509 // Methods from "question_graded_automatically" class.
510 // See "question/type/questionbase.php".
513 * Check a request for access to a file belonging to a combined feedback field.
515 * Fix a bug in Moodle 2.9 & 3.0, in which this method does not declare $args,
516 * so trying to use $args[0] always fails and images in feedback are not shown.
518 * @param question_attempt $qa the question attempt being displayed.
519 * @param question_display_options $options the options that control display of the question.
520 * @param string $filearea the name of the file area.
521 * @param array $args the remaining bits of the file path.
522 * @return bool whether access to the file should be allowed.
524 protected function check_combined_feedback_file_access($qa, $options, $filearea, $args = null) {
525 $state = $qa->get_state();
526 if (! $state->is_finished()) {
527 $response = $qa->get_last_qt_data();
528 if (! $this->is_gradable_response($response)) {
531 list($fraction, $state) = $this->grade_response($response);
533 if ($state->get_feedback_class().'feedback' == $filearea) {
534 return ($this->id
== reset($args));
543 * Returns response mform field name
547 public function get_response_fieldname(): string {
548 return 'response_' . $this->id
;
552 * Convert response data from mform into array
554 * @param array $response Form data
556 public function update_current_response(array $response) {
557 $name = $this->get_response_fieldname();
558 if (array_key_exists($name, $response)) {
559 $ids = explode(',', $response[$name]);
560 foreach ($ids as $i => $id) {
561 foreach ($this->answers
as $answer) {
562 if ($id == $answer->md5key
) {
563 $ids[$i] = $answer->id
;
568 $this->currentresponse
= $ids;
573 * Returns layoutclass
577 public function get_ordering_layoutclass(): string {
578 switch ($this->options
->layouttype
) {
579 case self
::LAYOUT_VERTICAL
:
581 case self
::LAYOUT_HORIZONTAL
:
584 return ''; // Shouldn't happen!
589 * Returns array of next answers
591 * @param array $answerids array of answers id
592 * @param bool $lastitem Include last item?
593 * @return array of id of next answer
595 public function get_next_answerids(array $answerids, bool $lastitem = false): array {
597 $imax = count($answerids);
602 $nextanswerid = $answerids[$imax];
605 for ($i = $imax; $i >= 0; $i--) {
606 $thisanswerid = $answerids[$i];
607 $nextanswerids[$thisanswerid] = $nextanswerid;
608 $nextanswerid = $thisanswerid;
610 return $nextanswerids;
614 * Returns prev and next answers array
616 * @param array $answerids array of answers id
617 * @param bool $all include all answers
618 * @return array of array('prev' => previd, 'next' => nextid)
620 public function get_previous_and_next_answerids(array $answerids, bool $all = false): array {
621 $prevnextanswerids = [];
624 while ($answerid = array_shift($next)) {
626 $prevnextanswerids[$answerid] = (object) [
631 $prevnextanswerids[$answerid] = (object) [
632 'prev' => [empty($prev) ?
0 : $prev[0]],
633 'next' => [empty($next) ?
0 : $next[0]],
636 array_unshift($prev, $answerid);
638 return $prevnextanswerids;
642 * Search for best ordered subset
644 * @param bool $contiguous A flag indicating whether only contiguous values should be considered for inclusion in the subset.
647 public function get_ordered_subset(bool $contiguous): array {
649 $positions = $this->get_ordered_positions($this->correctresponse
, $this->currentresponse
);
650 $subsets = $this->get_ordered_subsets($positions, $contiguous);
652 // The best subset (longest and leftmost).
655 // The length of the best subset
656 // initializing this to 1 means
657 // we ignore single item subsets.
660 foreach ($subsets as $subset) {
661 $count = count($subset);
662 if ($count > $bestcount) {
664 $bestsubset = $subset;
671 * Get array of right answer positions for current response
673 * @param array $correctresponse
674 * @param array $currentresponse
677 public function get_ordered_positions(array $correctresponse, array $currentresponse): array {
679 foreach ($currentresponse as $answerid) {
680 $positions[] = array_search($answerid, $correctresponse);
686 * Get all ordered subsets in the positions array
688 * @param array $positions maps an item's current position to its correct position
689 * @param bool $contiguous TRUE if searching only for contiguous subsets; otherwise FALSE
690 * @return array of ordered subsets from within the $positions array
692 public function get_ordered_subsets(array $positions, bool $contiguous): array {
694 // Var $subsets is the collection of all subsets within $positions.
697 // Loop through the values at each position.
698 foreach ($positions as $p => $value) {
700 // Is $value a "new" value that cannot be added to any $subsets found so far?
703 // An array of new and saved subsets to be added to $subsets.
706 // Append the current value to any subsets to which it belongs
707 // i.e. any subset whose end value is less than the current value.
708 foreach ($subsets as $s => $subset) {
710 // Get value at end of $subset.
711 $end = $positions[end($subset)];
715 case ($value == ($end +
1)):
716 // For a contiguous value, we simply append $p to the subset.
722 // If the $contiguous flag is set, we ignore non-contiguous values.
725 case ($value > $end):
726 // For a non-contiguous value, we save the subset so far,
727 // because a value between $end and $value may be found later,
728 // and then append $p to the subset.
736 // If this is a "new" value, add it as a new subset.
741 // Append any "new" subsets that were found during this iteration.
743 $subsets = array_merge($subsets, $new);
751 * Helper function for get_select_types, get_layout_types, get_grading_types
753 * @param array $types
755 * @return array|string array if $type is not specified and single string if $type is specified
756 * @throws coding_exception
758 public static function get_types(array $types, $type): array|
string {
759 if ($type === null) {
760 return $types; // Return all $types.
762 if (array_key_exists($type, $types)) {
763 return $types[$type]; // One $type.
766 throw new coding_exception('Invalid type: ' . $type);
770 * Returns available values and descriptions for field "selecttype"
772 * @param int|null $type
773 * @return array|string array if $type is not specified and single string if $type is specified
775 public static function get_select_types(int $type = null): array|
string {
776 $plugin = 'qtype_ordering';
778 self
::SELECT_ALL
=> get_string('selectall', $plugin),
779 self
::SELECT_RANDOM
=> get_string('selectrandom', $plugin),
780 self
::SELECT_CONTIGUOUS
=> get_string('selectcontiguous', $plugin),
782 return self
::get_types($types, $type);
786 * Returns available values and descriptions for field "layouttype"
788 * @param int|null $type
789 * @return array|string array if $type is not specified and single string if $type is specified
791 public static function get_layout_types(int $type = null): array|
string {
792 $plugin = 'qtype_ordering';
794 self
::LAYOUT_VERTICAL
=> get_string('vertical', $plugin),
795 self
::LAYOUT_HORIZONTAL
=> get_string('horizontal', $plugin),
797 return self
::get_types($types, $type);
801 * Returns available values and descriptions for field "gradingtype"
803 * @param int|null $type
804 * @return array|string array if $type is not specified and single string if $type is specified
806 public static function get_grading_types(int $type = null): array|
string {
807 $plugin = 'qtype_ordering';
809 self
::GRADING_ALL_OR_NOTHING
=> get_string('allornothing', $plugin),
810 self
::GRADING_ABSOLUTE_POSITION
=> get_string('absoluteposition', $plugin),
811 self
::GRADING_RELATIVE_TO_CORRECT
=> get_string('relativetocorrect', $plugin),
812 self
::GRADING_RELATIVE_NEXT_EXCLUDE_LAST
=> get_string('relativenextexcludelast', $plugin),
813 self
::GRADING_RELATIVE_NEXT_INCLUDE_LAST
=> get_string('relativenextincludelast', $plugin),
814 self
::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT
=> get_string('relativeonepreviousandnext', $plugin),
815 self
::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT
=> get_string('relativeallpreviousandnext', $plugin),
816 self
::GRADING_LONGEST_ORDERED_SUBSET
=> get_string('longestorderedsubset', $plugin),
817 self
::GRADING_LONGEST_CONTIGUOUS_SUBSET
=> get_string('longestcontiguoussubset', $plugin),
819 return self
::get_types($types, $type);
823 * Get the numbering styles supported.
825 * For each style, there should be a corresponding lang string 'numberingstylexxx' in the qtype_ordering language file,
826 * a case in the switch statement in number_in_style, and it should be listed in the definition of this column in install.xml.
828 * @param string|null $style The specific numbering style to retrieve.
829 * @return array|string Numbering style(s).
830 * The keys are style identifiers, and the values are the corresponding language strings.
832 public static function get_numbering_styles(string $style = null): array|
string {
833 $plugin = 'qtype_ordering';
835 'none' => get_string('numberingstylenone', $plugin),
836 'abc' => get_string('numberingstyleabc', $plugin),
837 'ABCD' => get_string('numberingstyleABCD', $plugin),
838 '123' => get_string('numberingstyle123', $plugin),
839 'iii' => get_string('numberingstyleiii', $plugin),
840 'IIII' => get_string('numberingstyleIIII', $plugin),
842 return self
::get_types($styles, $style);
846 * Return the number of subparts of this response that are correct|partial|incorrect.
848 * @param array $response A response.
849 * @return array Array of three elements: the number of correct subparts,
850 * the number of partial correct subparts and the number of incorrect subparts.
852 public function get_num_parts_right(array $response): array {
853 $this->update_current_response($response);
854 $gradingtype = $this->options
->gradingtype
;
859 list($correctresponse, $currentresponse) = $this->get_response_depend_on_grading_type($gradingtype);
861 foreach ($this->currentresponse
as $position => $answerid) {
862 [$fraction, $score, $maxscore] =
863 $this->get_fraction_maxscore_score_of_item($position, $answerid, $correctresponse, $currentresponse);
864 if (is_null($fraction)) {
868 if ($fraction > 0.999999) {
870 } else if ($fraction < 0.000001) {
877 return [$numright, $numpartial, $numincorrect];
881 * Returns the grade for one item, base on the fraction scale.
883 * @param int $position The position of the current response.
884 * @param int $answerid The answerid of the current response.
885 * @param array $correctresponse The correct response list base on grading type.
886 * @param array $currentresponse The current response list base on grading type.
889 protected function get_fraction_maxscore_score_of_item(
892 array $correctresponse,
893 array $currentresponse
895 $gradingtype = $this->options
->gradingtype
;
900 switch ($gradingtype) {
901 case self
::GRADING_ALL_OR_NOTHING
:
902 case self
::GRADING_ABSOLUTE_POSITION
:
903 if (isset($correctresponse[$position])) {
904 if ($correctresponse[$position] == $answerid) {
910 case self
::GRADING_RELATIVE_NEXT_EXCLUDE_LAST
:
911 case self
::GRADING_RELATIVE_NEXT_INCLUDE_LAST
:
912 if (isset($correctresponse[$answerid])) {
913 if (isset($currentresponse[$answerid]) && $currentresponse[$answerid] == $correctresponse[$answerid]) {
920 case self
::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT
:
921 case self
::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT
:
922 if (isset($correctresponse[$answerid])) {
924 $prev = $correctresponse[$answerid]->prev
;
925 $maxscore +
= count($prev);
926 $prev = array_intersect($prev, $currentresponse[$answerid]->prev
);
927 $score +
= count($prev);
928 $next = $correctresponse[$answerid]->next
;
929 $maxscore +
= count($next);
930 $next = array_intersect($next, $currentresponse[$answerid]->next
);
931 $score +
= count($next);
935 case self
::GRADING_LONGEST_ORDERED_SUBSET
:
936 case self
::GRADING_LONGEST_CONTIGUOUS_SUBSET
:
937 if (isset($correctresponse[$position])) {
938 if (isset($currentresponse[$position])) {
939 $score = $currentresponse[$position];
945 case self
::GRADING_RELATIVE_TO_CORRECT
:
946 if (isset($correctresponse[$position])) {
947 $maxscore = (count($correctresponse) - 1);
948 $answerid = $currentresponse[$position];
949 $correctposition = array_search($answerid, $correctresponse);
950 $score = ($maxscore - abs($correctposition - $position));
957 $fraction = $maxscore ?
$score / $maxscore : $maxscore;
959 return [$fraction, $score, $maxscore];
963 * Get correcresponse and currentinfo depending on grading type.
965 * @param string $gradingtype The kind of grading.
966 * @return array Correctresponse and currentresponsescore in one array.
968 protected function get_response_depend_on_grading_type(string $gradingtype): array {
970 $correctresponse = [];
971 $currentresponse = [];
972 switch ($gradingtype) {
973 case self
::GRADING_ALL_OR_NOTHING
:
974 case self
::GRADING_ABSOLUTE_POSITION
:
975 case self
::GRADING_RELATIVE_TO_CORRECT
:
976 $correctresponse = $this->correctresponse
;
977 $currentresponse = $this->currentresponse
;
980 case self
::GRADING_RELATIVE_NEXT_EXCLUDE_LAST
:
981 case self
::GRADING_RELATIVE_NEXT_INCLUDE_LAST
:
982 $lastitem = ($gradingtype == self
::GRADING_RELATIVE_NEXT_INCLUDE_LAST
);
983 $correctresponse = $this->get_next_answerids($this->correctresponse
, $lastitem);
984 $currentresponse = $this->get_next_answerids($this->currentresponse
, $lastitem);
987 case self
::GRADING_RELATIVE_ONE_PREVIOUS_AND_NEXT
:
988 case self
::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT
:
989 $all = ($gradingtype == self
::GRADING_RELATIVE_ALL_PREVIOUS_AND_NEXT
);
990 $correctresponse = $this->get_previous_and_next_answerids($this->correctresponse
, $all);
991 $currentresponse = $this->get_previous_and_next_answerids($this->currentresponse
, $all);
994 case self
::GRADING_LONGEST_ORDERED_SUBSET
:
995 case self
::GRADING_LONGEST_CONTIGUOUS_SUBSET
:
996 $correctresponse = $this->correctresponse
;
997 $currentresponse = $this->currentresponse
;
998 $contiguous = ($gradingtype == self
::GRADING_LONGEST_CONTIGUOUS_SUBSET
);
999 $subset = $this->get_ordered_subset($contiguous);
1000 foreach ($currentresponse as $position => $answerid) {
1001 if (array_search($position, $subset) === false) {
1002 $currentresponse[$position] = 0;
1004 $currentresponse[$position] = 1;
1010 return [$correctresponse, $currentresponse];
1014 * Returns score for one item depending on correctness and question settings.
1016 * @param question_definition $question question definition object
1017 * @param int $position The position of the current response.
1018 * @param int $answerid The answerid of the current response.
1019 * @return array (score, maxscore, fraction, percent, class)
1021 public function get_ordering_item_score(question_definition
$question, int $position, int $answerid): array {
1023 if (!isset($this->itemscores
[$position])) {
1025 [$correctresponse, $currentresponse] = $this->get_response_depend_on_grading_type($question->options
->gradingtype
);
1027 $percent = 0; // 100 * $fraction.
1028 [$fraction, $score, $maxscore] =
1029 $this->get_fraction_maxscore_score_of_item($position, $answerid, $correctresponse, $currentresponse);
1031 if ($maxscore === null) {
1032 // An unscored item is either an illegal item
1033 // or last item of RELATIVE_NEXT_EXCLUDE_LAST
1034 // or an item in an incorrect ALL_OR_NOTHING
1035 // or an item from an unrecognized grading type.
1036 $class = 'unscored';
1038 if ($maxscore > 0) {
1039 $percent = round(100 * $fraction, 0);
1041 $class = match (true) {
1042 $fraction > 0.999999 => 'correct',
1043 $fraction < 0.000001 => 'incorrect',
1044 $fraction >= 0.66 => 'partial66',
1045 $fraction >= 0.33 => 'partial33',
1046 default => 'partial00',
1050 $score = [$score, $maxscore, $fraction, $percent, $class];
1051 $this->itemscores
[$position] = $score;
1054 return $this->itemscores
[$position];