element.
+ $divclass = 'r' . ($i % 2);
+ if ($displayoptions->correctness && $isselected) {
+ $divclass .= ' ' . $feedbackclass;
+ }
+ $output .= html_writer::start_div($divclass);
+
+ // Now add the
.
+ $output .= html_writer::empty_tag('input', $inputattributes);
+ $output .= $label['html'];
+
+ // Close the option's .
+ $output .= html_writer::end_div();
+ }
+
+ // Close the option group's
.
+ $output .= html_writer::end_tag('fieldset');
+
+ return $output;
+ }
+
+ /**
+ * Create a element for a given input control (e. g. a text field). Returns the
+ * HTML and the label's ID.
+ *
+ * @param string $text the label's text
+ * @param string $inputid ID of the input control for which the label is created
+ * @param array $additionalattributes possibility to add custom attributes, attribute name => value
+ * @return array 'id' => label's ID to be used in 'aria-labelledby' attribute, 'html' => HTML code
+ */
+ protected function create_label_for_input(string $text, string $inputid, array $additionalattributes = []): array {
+ $labelid = 'lbl_' . str_replace(':', '__', $inputid);
+ $attributes = [
+ 'class' => 'subq accesshide',
+ 'for' => $inputid,
+ 'id' => $labelid,
+ ];
+
+ // Merging the additional attributes with the default attributes; left array has precedence when
+ // using the + operator.
+ $attributes = $additionalattributes + $attributes;
+
+ return [
+ 'id' => $labelid,
+ 'html' => html_writer::tag('label', $text, $attributes),
+ ];
+ }
+
+ /**
+ * Create a field for a multiple choice answer input.
+ *
+ * @param qtype_formulas_part $part question part
+ * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
+ * @param question_attempt $qa question attempt that will be displayed on the page
+ * @param array $answeroptions array of strings containing the answer options to choose from
+ * @param question_display_options $displayoptions controls what should and should not be displayed
+ * @return string HTML fragment
+ */
+ protected function create_dropdown_mc_answer(qtype_formulas_part $part, $answerindex, question_attempt $qa,
+ array $answeroptions, question_display_options $displayoptions): string {
+ /** @var qype_formulas_question $question */
+ $question = $qa->get_question();
+
+ $variablename = "{$part->partindex}_{$answerindex}";
+ $currentanswer = $qa->get_last_qt_var($variablename);
+ $inputname = $qa->get_qt_field_name($variablename);
+
+ $inputattributes['name'] = $inputname;
+ $inputattributes['value'] = $currentanswer;
+ $inputattributes['id'] = $inputname;
+
+ $label = $this->create_label_for_input(
+ $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
+ $inputname
+ );
+ $inputattributes['aria-labelledby'] = $label['id'];
+
+ if ($displayoptions->readonly) {
+ $inputattributes['disabled'] = 'disabled';
+ }
+
+ // First, we open a around the dropdown field and its accessibility label.
+ $output = html_writer::start_tag('span', ['class' => 'formulas_menu']);
+ $output .= $label['html'];
+
+ // Iterate over all options.
+ $entries = [];
+ foreach ($answeroptions as $optiontext) {
+ $entries[] = $question->format_text(
+ $optiontext, $part->subqtextformat , $qa, 'qtype_formulas', 'answersubqtext', $part->id, false
+ );
+ }
+ $output .= html_writer::select($entries, $inputname, $currentanswer, ['' => ''], $inputattributes);
+ $output .= html_writer::end_tag('span');
+
+ return $output;
+ }
+
+ /**
+ * Generate the right label for the input control, depending on the number of answers in the given part
+ * and the number of parts in the question. Special cases (combined field, unit field) are also taken
+ * into account. Returns the appropriate string from the language file. Examples are "Answer field for
+ * part X", "Answer field X for part Y" or "Answer and unit for part X".
+ *
+ * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
+ * @param int $totalanswers number of answers for the given part
+ * @param int $partindex number of the part (starting at 0) in this question
+ * @param int $totalparts number of parts in the question
+ * @return string localized string
+ */
+ protected function generate_accessibility_label_text($answerindex, int $totalanswers, int $partindex,
+ int $totalparts): string {
+
+ // Some language strings need parameters.
+ $labeldata = new stdClass();
+
+ // The language strings start with 'answerunit' for a separate unit field, 'answercombinedunit' for
+ // a combined field, 'answercoordinate' for an answer field when there are multiple answers in the
+ // part or just 'answer' if there is a single field.
+ $labelstring = 'answer';
+ if ($answerindex === self::UNIT_FIELD) {
+ $labelstring .= 'unit';
+ } else if ($answerindex === self::COMBINED_FIELD) {
+ $labelstring .= 'combinedunit';
+ } else if ($totalanswers > 1) {
+ $labelstring .= 'coordinate';
+ $labeldata->numanswer = $answerindex + 1;
+ }
+
+ // The language strings end with 'multi' for multi-part questions or 'single' for single-part
+ // questions.
+ if ($totalparts > 1) {
+ $labelstring .= 'multi';
+ $labeldata->part = $partindex + 1;
+ } else {
+ $labelstring .= 'single';
+ }
+
+ return get_string($labelstring, 'qtype_formulas', $labeldata);
+ }
+
+ /**
+ * Create an field.
+ *
+ * @param qtype_formulas_part $part question part
+ * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
+ * @param question_attempt $qa question attempt that will be displayed on the page
+ * @param question_display_options $displayoptions controls what should and should not be displayed
+ * @param string $feedbackclass
+ * @return string HTML fragment
+ */
+ protected function create_input_box(qtype_formulas_part $part, $answerindex,
+ question_attempt $qa, question_display_options $displayoptions, string $feedbackclass = ''): string {
+ /** @var qype_formulas_question $question */
+ $question = $qa->get_question();
+
+ // The variable name will be N_ for the (single) combined unit field of part N,
+ // or N_M for answer #M in part #N. If #M is equal to the part's numbox (i. e. the
+ // number of answers), it is a unit field; note that the fields are numbered starting
+ // from 0, so with 3 answers, we have N_0, N_1, N_2 and only use N_3 if there is a
+ // unit.
+ $variablename = $part->partindex . '_';
+ if ($answerindex === self::UNIT_FIELD) {
+ $variablename .= $part->numbox;
+ } else {
+ $variablename .= ($answerindex === self::COMBINED_FIELD ? '' : $answerindex);
+ }
+
+ $currentanswer = $qa->get_last_qt_var($variablename);
+ $inputname = $qa->get_qt_field_name($variablename);
+
+ // Text fields will have a tooltip attached. The tooltip's content depends on the
+ // answer type. Special tooltips exist for combined or separate unit fields.
+ switch ($part->answertype) {
+ case qtype_formulas::ANSWER_TYPE_NUMERIC:
+ $titlestring = 'numeric';
+ break;
+ case qtype_formulas::ANSWER_TYPE_NUMERICAL_FORMULA:
+ $titlestring = 'numerical_formula';
+ break;
+ case qtype_formulas::ANSWER_TYPE_ALGEBRAIC:
+ $titlestring = 'algebraic_formula';
+ break;
+ case qtype_formulas::ANSWER_TYPE_NUMBER:
+ default:
+ $titlestring = 'number';
+ }
+ if ($answerindex === self::COMBINED_FIELD) {
+ $titlestring .= '_unit';
+ }
+ if ($answerindex === self::UNIT_FIELD) {
+ $titlestring = 'unit';
+ }
+ $title = get_string($titlestring, 'qtype_formulas');
+
+ $inputattributes = [
+ 'type' => 'text',
+ 'name' => $inputname,
+ 'value' => $currentanswer,
+ 'id' => $inputname,
+
+ 'data-answertype' => ($answerindex === self::UNIT_FIELD ? 'unit' : $part->answertype),
+ 'data-withunit' => ($answerindex === self::COMBINED_FIELD ? '1' : '0'),
+
+ 'data-toggle' => 'tooltip',
+ 'data-title' => $title,
+ 'data-custom-class' => 'qtype_formulas-tooltip',
+ 'title' => $title,
+ 'class' => "form-control formulas_{$titlestring} {$feedbackclass}",
+ 'maxlength' => 128,
+ ];
+
+ if ($displayoptions->readonly) {
+ $inputattributes['readonly'] = 'readonly';
+ }
+
+ $label = $this->create_label_for_input(
+ $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
+ $inputname
+ );
+ $inputattributes['aria-labelledby'] = $label['id'];
+
+ $output = $label['html'];
+ $output .= html_writer::empty_tag('input', $inputattributes);
+
+ return $output;
+ }
+
/**
* Return the part's text with variables replaced by their values.
*
- * @param question_attempt $qa
- * @param question_display_options $options
- * @param int $i part index
- * @param object $sub class and image for the part feedback
- * @return string
+ * @param question_attempt $qa question attempt that will be displayed on the page
+ * @param question_display_options $options controls what should and should not be displayed
+ * @param qtype_formulas_part $part question part
+ * @param stdClass $sub class and symbol for the part feedback
+ * @return string HTML fragment
*/
- public function get_part_formulation(question_attempt $qa, question_display_options $options, $i, $sub) {
+ public function get_part_formulation(question_attempt $qa, question_display_options $options,
+ qtype_formulas_part $part, stdClass $sub): string {
/** @var qype_formulas_question $question */
$question = $qa->get_question();
- $part = &$question->parts[$i];
// Clone the part's evaluator and remove special variables like _0 etc., because they must
// not be substituted here; otherwise, we would lose input boxes.
@@ -249,300 +533,83 @@ public function get_part_formulation(question_attempt $qa, question_display_opti
$evaluator->remove_special_vars();
$text = $evaluator->substitute_variables_in_text($part->subqtext);
- $subqreplaced = $question->format_text($text,
- $part->subqtextformat, $qa, 'qtype_formulas', 'answersubqtext', $part->id, false);
- $types = [0 => 'number', 10 => 'numeric', 100 => 'numerical_formula', 1000 => 'algebraic_formula'];
- $gradingtype = ($part->answertype != 10 && $part->answertype != 100 && $part->answertype != 1000) ? 0 : $part->answertype;
- $gtype = $types[$gradingtype];
+ $subqreplaced = $question->format_text(
+ $text, $part->subqtextformat, $qa, 'qtype_formulas', 'answersubqtext', $part->id, false
+ );
// Get the set of defined placeholders and their options.
$boxes = $part->scan_for_answer_boxes($subqreplaced);
- // Append missing placholders at the end of part.
- foreach (range(0, $part->numbox) as $j) {
- $placeholder = ($j == $part->numbox) ? "_u" : "_$j";
+
+ // Append missing placholders at the end of part. We do not put a space before the opening
+ // or after the closing brace, in order to get {_0}{_u} for questions with one answer and
+ // a unit. This makes sure that the question will receive a combined unit field.
+ for ($i = 0; $i <= $part->numbox; $i++) {
+ // If no unit has been set, we do not append the {_u} placeholder.
+ if ($i == $part->numbox && empty($part->postunit)) {
+ continue;
+ }
+ $placeholder = ($i == $part->numbox) ? '_u' : "_{$i}";
+ // If the placeholder does not exist yet, we create it with default settings, i. e. no multi-choice.
if (!array_key_exists($placeholder, $boxes)) {
- $boxes[$placeholder] = ['placeholder' => "{".$placeholder."}", 'options' => '', 'dropdown' => false];
- $subqreplaced .= "{".$placeholder."}"; // Appended at the end.
+ $boxes[$placeholder] = ['placeholder' => '{' . $placeholder . '}', 'options' => '', 'dropdown' => false];
+ $subqreplaced .= '{' . $placeholder . '}';
}
}
// If part has combined unit answer input.
if ($part->has_combined_unit_field()) {
- $variablename = "{$i}_";
- $currentanswer = $qa->get_last_qt_var($variablename);
- $inputname = $qa->get_qt_field_name($variablename);
- $title = get_string($gtype . ($part->postunit == '' ? '' : '_unit'), 'qtype_formulas');
- $inputattributes = [
- 'type' => 'text',
- 'data-answertype' => $part->answertype,
- 'data-withunit' => '1',
- 'name' => $inputname,
- 'data-toggle' => 'tooltip',
- 'data-title' => $title,
- 'title' => $title,
- 'value' => $currentanswer,
- 'id' => $inputname,
- 'class' => 'form-control formulas_' . $gtype . '_unit ' . $sub->feedbackclass,
- 'maxlength' => 128,
- 'aria-labelledby' => 'lbl_' . str_replace(':', '__', $inputname),
- ];
-
- if ($options->readonly) {
- $inputattributes['readonly'] = 'readonly';
- }
- // Create a meaningful label for accessibility.
- $a = new stdClass();
- $a->part = $i + 1;
- $a->numanswer = '';
- if ($question->numparts == 1) {
- $label = get_string('answercombinedunitsingle', 'qtype_formulas', $a);
- } else {
- $label = get_string('answercombinedunitmulti', 'qtype_formulas', $a);
- }
- $input = html_writer::tag(
- 'label',
- $label,
- [
- 'class' => 'subq accesshide',
- 'for' => $inputattributes['id'],
- 'id' => 'lbl_' . str_replace(':', '__', $inputattributes['id']),
- ]
- );
- $input .= html_writer::empty_tag('input', $inputattributes);
- $subqreplaced = str_replace("{_0}{_u}", $input, $subqreplaced);
- }
-
- // Get the set of string for each candidate input box {_0}, {_1}, ..., {_u}.
- $inputs = [];
- foreach (range(0, $part->numbox) as $j) { // Replace the input box for each placeholder {_0}, {_1} ...
- $placeholder = ($j == $part->numbox) ? "_u" : "_$j"; // The last one is unit.
- $variablename = "{$i}_$j";
- $currentanswer = $qa->get_last_qt_var($variablename);
- $inputname = $qa->get_qt_field_name($variablename);
- $title = get_string($placeholder == '_u' ? 'unit' : $gtype, 'qtype_formulas');
- $inputattributes = [
- 'name' => $inputname,
- 'value' => $currentanswer,
- 'id' => $inputname,
- 'data-toggle' => 'tooltip',
- 'data-title' => $title,
- 'title' => $title,
- 'maxlength' => 128,
- 'aria-labelledby' => 'lbl_' . str_replace(':', '__', $inputname),
- ];
- if ($options->readonly) {
- $inputattributes['readonly'] = 'readonly';
+ $combinedfieldhtml = $this->create_input_box($part, self::COMBINED_FIELD, $qa, $options, $sub->feedbackclass);
+ return str_replace('{_0}{_u}', $combinedfieldhtml, $subqreplaced);
+ }
+
+ // Iterate over all boxes again, this time creating the appropriate input control and insert it
+ // at the position indicated by the placeholder.
+ for ($i = 0; $i <= $part->numbox; $i++) {
+ // For normal answer fields, the placeholder is {_N} with N being the number of the
+ // answer, starting from 0. The unit field, if there is one, comes last and has the
+ // {_u} placeholder.
+ if ($i < $part->numbox) {
+ $answerindex = $i;
+ $placeholder = "_$i";
+ } else if (!empty($part->postunit)) {
+ $answerindex = self::UNIT_FIELD;
+ $placeholder = '_u';
}
- $stexts = null;
- if (strlen($boxes[$placeholder]['options']) != 0) { // Then it's a multichoice answer..
+ // If the user has requested a multi-choice element, they must have specified an array
+ // variable containing the options. We try to fetch that variable. If this fails, we
+ // simply continue and build a text field instead.
+ $optiontexts = null;
+ if (!empty($boxes[$placeholder]['options'])) {
try {
- $stexts = $part->evaluator->export_single_variable($boxes[$placeholder]['options']);
+ $optiontexts = $part->evaluator->export_single_variable($boxes[$placeholder]['options']);
} catch (Exception $e) {
// TODO: use non-capturing catch.
unset($e);
}
}
- // Coordinate as multichoice options.
- if ($stexts != null) {
- if ($boxes[$placeholder]['dropdown']) {
- // Select menu.
- if ($options->readonly) {
- $inputattributes['disabled'] = 'disabled';
- }
- $choices = [];
- foreach ($stexts->value as $x => $mctxt) {
- $choices[$x] = $question->format_text($mctxt, $part->subqtextformat , $qa,
- 'qtype_formulas', 'answersubqtext', $part->id, false);
- }
- unset($inputattributes['data-toggle']);
- unset($inputattributes['data-title']);
- $select = html_writer::select($choices, $inputname,
- $currentanswer, ['' => ''], $inputattributes);
- $output = html_writer::start_tag('span', ['class' => 'formulas_menu']);
- $a = new stdClass();
- $a->numanswer = $j + 1;
- $a->part = $i + 1;
- if (count($question->parts) > 1) {
- $labeltext = get_string('answercoordinatemulti', 'qtype_formulas', $a);
- } else {
- $labeltext = get_string('answercoordinatesingle', 'qtype_formulas', $a);
- }
- $output .= html_writer::tag(
- 'label',
- $labeltext,
- [
- 'class' => 'subq accesshide',
- 'for' => $inputattributes['id'],
- 'id' => 'lbl_' . str_replace(':', '__', $inputattributes['id']),
- ]
- );
- $output .= $select;
- $output .= html_writer::end_tag('span');
- $inputs[$placeholder] = $output;
- } else {
- // Multichoice single question.
- $inputattributes['type'] = 'radio';
- if ($options->readonly) {
- $inputattributes['disabled'] = 'disabled';
- }
- $output = $this->all_choices_wrapper_start();
- foreach ($stexts->value as $x => $mctxt) {
- $mctxt = html_writer::span($this->number_in_style($x, $question->answernumbering), 'answernumber')
- . $question->format_text($mctxt, $part->subqtextformat , $qa,
- 'qtype_formulas', 'answersubqtext', $part->id, false);
- $inputattributes['id'] = $inputname.'_'.$x;
- $inputattributes['value'] = $x;
- $inputattributes['aria-labelledby'] = 'lbl_' . str_replace(':', '__', $inputattributes['id']);
- $isselected = ($currentanswer != '' && $x == $currentanswer);
- $class = 'r' . ($x % 2);
- if ($isselected) {
- $inputattributes['checked'] = 'checked';
- } else {
- unset($inputattributes['checked']);
- }
- if ($options->correctness && $isselected) {
- $class .= ' ' . $sub->feedbackclass;
- }
- $output .= $this->choice_wrapper_start($class);
- unset($inputattributes['data-toggle']);
- unset($inputattributes['data-title']);
- $output .= html_writer::empty_tag('input', $inputattributes);
- $output .= html_writer::tag(
- 'label',
- $mctxt,
- [
- 'for' => $inputattributes['id'],
- 'class' => 'm-l-1',
- 'id' => 'lbl_' . str_replace(':', '__', $inputattributes['id']),
- ]
- );
- $output .= $this->choice_wrapper_end();
- }
- $output .= $this->all_choices_wrapper_end();
- $inputs[$placeholder] = $output;
- }
- continue;
- }
- // Coordinate as shortanswer question.
- $inputs[$placeholder] = '';
- $inputattributes['type'] = 'text';
- if ($options->readonly) {
- $inputattributes['readonly'] = 'readonly';
- }
- if ($j == $part->numbox) {
- // Check if it's an input for unit.
- if (strlen($part->postunit) > 0) {
- $inputattributes['title'] = get_string('unit', 'qtype_formulas');
- $inputattributes['class'] = 'form-control formulas_unit '.$sub->unitfeedbackclass;
- $inputattributes['data-title'] = get_string('unit', 'qtype_formulas');
- $inputattributes['data-toggle'] = 'tooltip';
- $inputattributes['data-answertype'] = 'unit';
- $a = new stdClass();
- $a->part = $i + 1;
- $a->numanswer = $j + 1;
- if ($question->numparts == 1) {
- $label = get_string('answerunitsingle', 'qtype_formulas', $a);
- } else {
- $label = get_string('answerunitmulti', 'qtype_formulas', $a);
- }
- $inputs[$placeholder] = html_writer::tag(
- 'label',
- $label,
- [
- 'class' => 'subq accesshide',
- 'for' => $inputattributes['id'],
- 'id' => 'lbl_' . str_replace(':', '__', $inputattributes['id']),
- ]
- );
- $inputs[$placeholder] .= html_writer::empty_tag('input', $inputattributes);
- }
+ if ($optiontexts === null) {
+ $inputfieldhtml = $this->create_input_box($part, $answerindex, $qa, $options, $sub->feedbackclass);
+ } else if ($boxes[$placeholder]['dropdown']) {
+ $inputfieldhtml = $this->create_dropdown_mc_answer($part, $i, $qa, $optiontexts->value, $options);
} else {
- $inputattributes['title'] = get_string($gtype, 'qtype_formulas');
- $inputattributes['class'] = 'form-control formulas_'.$gtype.' '.$sub->boxfeedbackclass;
- $inputattributes['data-toggle'] = 'tooltip';
- $inputattributes['data-title'] = get_string($gtype, 'qtype_formulas');
- $inputattributes['aria-labelledby'] = 'lbl_' . str_replace(':', '__', $inputattributes['id']);
- $inputattributes['data-answertype'] = $part->answertype;
- $inputattributes['data-withunit'] = '0';
- $a = new stdClass();
- $a->part = $i + 1;
- $a->numanswer = $j + 1;
- if ($part->numbox == 1) {
- if ($question->numparts == 1) {
- $label = get_string('answersingle', 'qtype_formulas', $a);
- } else {
- $label = get_string('answermulti', 'qtype_formulas', $a);
- }
- } else {
- if ($question->numparts == 1) {
- $label = get_string('answercoordinatesingle', 'qtype_formulas', $a);
- } else {
- $label = get_string('answercoordinatemulti', 'qtype_formulas', $a);
- }
- }
- $inputs[$placeholder] = html_writer::tag(
- 'label',
- $label,
- [
- 'class' => 'subq accesshide',
- 'for' => $inputattributes['id'],
- 'id' => 'lbl_' . str_replace(':', '__', $inputattributes['id']),
- ]
+ $inputfieldhtml = $this->create_radio_mc_answer(
+ $part, $i, $qa, $optiontexts->value, $options, $sub->feedbackclass
);
- $inputs[$placeholder] .= html_writer::empty_tag('input', $inputattributes);
}
- }
- foreach ($inputs as $placeholder => $replacement) {
- $subqreplaced = preg_replace('/'.$boxes[$placeholder]['placeholder'].'/', $replacement, $subqreplaced, 1);
+ $subqreplaced = str_replace($boxes[$placeholder]['placeholder'], $inputfieldhtml, $subqreplaced);
}
- return $subqreplaced;
- }
-
- /**
- * Generate HTML code to be included before each choice in multiple choice questions.
- *
- * @param string $class class attribute value
- * @return string
- */
- protected function choice_wrapper_start($class) {
- return html_writer::start_tag('div', ['class' => $class]);
- }
-
- /**
- * Generate HTML code to be included after each choice in multiple choice questions.
- *
- * @return string
- */
- protected function choice_wrapper_end() {
- return html_writer::end_tag('div');
- }
-
- /**
- * Generate HTML code to be included before all choices in multiple choice questions.
- *
- * @return string
- */
- protected function all_choices_wrapper_start() {
- return html_writer::start_tag('div', ['class' => 'multichoice_answer']);
- }
- /**
- * Generate HTML code to be included after all choices in multiple choice questions.
- *
- * @return string
- */
- protected function all_choices_wrapper_end() {
- return html_writer::end_tag('div');
+ return $subqreplaced;
}
/**
* Correct response for the question. This is not needed for the Formulas question, because
* answers are relative to parts.
*
- * @param question_attempt $qa the question attempt to display
+ * @param question_attempt $qa question attempt that will be displayed on the page
* @return string empty string
*/
public function correct_response(question_attempt $qa) {
@@ -552,48 +619,46 @@ public function correct_response(question_attempt $qa) {
/**
* Generate an automatic description of the correct response for a given part.
*
- * @param int $i part index
- * @param question_attempt $qa question attempt to display
+ * @param qtype_formulas_part $part question part
* @return string HTML fragment
*/
- public function part_correct_response($i, question_attempt $qa) {
- /** @var qtype_formulas_question $question */
- $question = $qa->get_question();
- $answers = $question->parts[$i]->get_correct_response(true);
- $answertext = implode(', ', $answers);
+ public function part_correct_response($part) {
+ $answers = $part->get_correct_response(true);
+ $answertext = implode('; ', $answers);
- if ($question->parts[$i]->answernotunique) {
- $string = 'correctansweris';
- } else {
- $string = 'uniquecorrectansweris';
- }
- return html_writer::nonempty_tag('div', get_string($string, 'qtype_formulas', $answertext),
- ['class' => 'formulaspartcorrectanswer']);
+ $string = ($part->answernotunique ? 'correctansweris' : 'uniquecorrectansweris');
+ return html_writer::nonempty_tag(
+ 'div', get_string($string, 'qtype_formulas', $answertext), ['class' => 'formulaspartcorrectanswer']
+ );
}
/**
* Generate a brief statement of how many sub-parts of this question the
* student got right.
- * @param question_attempt $qa the question attempt to display.
- * @return string HTML fragment.
+ *
+ * @param question_attempt $qa question attempt that will be displayed on the page
+ * @return string HTML fragment
*/
protected function num_parts_correct(question_attempt $qa) {
+ /** @var qtype_formulas_question $question */
+ $question = $qa->get_question();
$response = $qa->get_last_qt_data();
- if (!$qa->get_question()->is_gradable_response($response)) {
+ if (!$question->is_gradable_response($response)) {
return '';
}
- $numright = $qa->get_question()->get_num_parts_right($response);
- if ($numright[0] === 1) {
+
+ $numright = $question->get_num_parts_right($response)[0];
+ if ($numright === 1) {
return get_string('yougotoneright', 'qtype_formulas');
} else {
- return get_string('yougotnright', 'qtype_formulas', $numright[0]);
+ return get_string('yougotnright', 'qtype_formulas', $numright);
}
}
/**
- * We need to owerwrite this method to replace global variables by their value
+ * We need to owerwrite this method to replace global variables by their value.
*
- * @param question_attempt $qa the question attempt to display
+ * @param question_attempt $qa question attempt that will be displayed on the page
* @param question_hint $hint the hint to be shown
* @return string HTML fragment
*/
@@ -602,44 +667,51 @@ protected function hint(question_attempt $qa, question_hint $hint) {
$question = $qa->get_question();
$hint->hint = $question->evaluator->substitute_variables_in_text($hint->hint);
- return html_writer::nonempty_tag('div', $qa->get_question()->format_hint($hint, $qa), ['class' => 'hint']);
+ return html_writer::nonempty_tag('div', $question->format_hint($hint, $qa), ['class' => 'hint']);
}
/**
* Generate HTML fragment for the question's combined feedback.
*
- * @param question_attempt $qa question attempt being displayed
- * @return string
+ * @param question_attempt $qa question attempt that will be displayed on the page
+ * @return string HTML fragment
*/
protected function combined_feedback(question_attempt $qa) {
+ /** @var qtype_formulas_question $question */
$question = $qa->get_question();
$state = $qa->get_state();
-
if (!$state->is_finished()) {
$response = $qa->get_last_qt_data();
- if (!$qa->get_question()->is_gradable_response($response)) {
+ if (!$question->is_gradable_response($response)) {
return '';
}
- list($notused, $state) = $qa->get_question()->grade_response($response);
+ $state = $question->grade_response($response)[1];
}
- $feedback = '';
- $field = $state->get_feedback_class() . 'feedback';
- $format = $state->get_feedback_class() . 'feedbackformat';
- if ($question->$field) {
- $feedback .= $question->format_text($question->$field, $question->$format,
- $qa, 'question', $field, $question->id, false);
+ // The feedback will be in ->correctfeedback, ->partiallycorrectfeedback or ->incorrectfeedback,
+ // with the corresponding ->...feedbackformat setting. We create the property names here to simplify
+ // access.
+ $fieldname = $state->get_feedback_class() . 'feedback';
+ $formatname = $state->get_feedback_class() . 'feedbackformat';
+
+ // If there is no feedback, we return an empty string.
+ if (strlen(trim($question->$fieldname)) === 0) {
+ return '';
}
- return $feedback;
+ // Otherwise, we return the appropriate feedback. The text is run through format_text() to have
+ // variables replaced.
+ return $question->format_text(
+ $question->$fieldname, $question->$formatname, $qa, 'question', $fieldname, $question->id, false
+ );
}
/**
* Generate the specific feedback. This is feedback that varies according to
* the response the student gave.
*
- * @param question_attempt $qa question attempt being displayed
+ * @param question_attempt $qa question attempt that will be displayed on the page
* @return string
*/
public function specific_feedback(question_attempt $qa) {
@@ -649,18 +721,19 @@ public function specific_feedback(question_attempt $qa) {
/**
* Gereate the part's general feedback. This is feedback is shown to all students.
*
- * @param question_attempt $qa question attempt being displayed
+ * @param question_attempt $qa question attempt that will be displayed on the page
* @param question_display_options $options controls what should and should not be displayed
- * @param qtype_formulas_part $part the question part
+ * @param qtype_formulas_part $part question part
* @return string HTML fragment
*/
protected function part_general_feedback(question_attempt $qa, question_display_options $options, qtype_formulas_part $part) {
- if ($part->feedback == '') {
+ // If the part's general feedback is empty, we can leave right away, returning an empty string.
+ if (strlen(trim($part->feedback)) === 0) {
return '';
}
- $feedback = '';
$gradingdetails = '';
+ /** @var qtype_formulas_question $question */
$question = $qa->get_question();
$state = $qa->get_state();
@@ -674,8 +747,7 @@ protected function part_general_feedback(question_attempt $qa, question_display_
$showfeedback = $options->feedback && $state->get_feedback_class() != '';
if ($showfeedback) {
// Clone the part's evaluator and substitute local / grading vars first.
- $evaluator = clone $part->evaluator;
- $feedbacktext = $evaluator->substitute_variables_in_text($part->feedback);
+ $feedbacktext = $part->evaluator->substitute_variables_in_text($part->feedback);
$feedbacktext = $question->format_text(
$feedbacktext,
@@ -696,28 +768,22 @@ protected function part_general_feedback(question_attempt $qa, question_display_
/**
* Generate HTML fragment for the part's combined feedback.
*
- * @param question_attempt $qa question attempt being displayed
+ * @param question_attempt $qa question attempt that will be displayed on the page
* @param question_display_options $options controls what should and should not be displayed
- * @param qtype_formulas_part $part the question part
+ * @param qtype_formulas_part $part question part
* @param float $fraction the obtained grade
* @return string HTML fragment
*/
- protected function part_combined_feedback(
- question_attempt $qa,
- question_display_options $options,
- qtype_formulas_part $part,
- float $fraction
- ) {
+ protected function part_combined_feedback(question_attempt $qa, question_display_options $options,
+ qtype_formulas_part $part, float $fraction): string {
$feedback = '';
$showfeedback = false;
- $gradingdetails = '';
+ /** @var qtype_formulas_question $question */
$question = $qa->get_question();
$state = $qa->get_state();
$feedbackclass = $state->get_feedback_class();
if ($qa->get_behaviour_name() == 'adaptivemultipart') {
- // This is rather a hack, but it will probably work.
- $renderer = $this->page->get_renderer('qbehaviour_adaptivemultipart');
$details = $qa->get_behaviour()->get_part_mark_details($part->partindex);
$feedbackclass = $details->state->get_feedback_class();
} else {
@@ -730,8 +796,7 @@ protected function part_combined_feedback(
$format = 'part' . $feedbackclass . 'fbformat';
if ($part->$field) {
// Clone the part's evaluator and substitute local / grading vars first.
- $evaluator = clone $part->evaluator;
- $part->$field = $evaluator->substitute_variables_in_text($part->$field);
+ $part->$field = $part->evaluator->substitute_variables_in_text($part->$field);
$feedback = $question->format_text($part->$field, $part->$format,
$qa, 'qtype_formulas', $field, $part->id, false);
}
diff --git a/tests/behat/mobile.feature b/tests/behat/mobile.feature
index 9ce2111d..ec088109 100644
--- a/tests/behat/mobile.feature
+++ b/tests/behat/mobile.feature
@@ -145,8 +145,8 @@ Feature: Mobile compatibility
Scenario: Try to answer a question with two parts, one drowdown multiple choice in each of them
When I press "Quiz 8" in the app
And I press "Attempt quiz now" in the app
- And I set the field "Answer field 1 for part 1" to "Cat"
- And I set the field "Answer field 1 for part 2" to "Blue"
+ And I set the field "Answer for part 1" to "Cat"
+ And I set the field "Answer for part 2" to "Blue"
And I press "Submit" in the app
And I press "Submit all and finish" in the app
And I press "Submit" near "Once you submit" in the app
diff --git a/tests/behat/quiz.feature b/tests/behat/quiz.feature
index f5240b55..02dbe76d 100644
--- a/tests/behat/quiz.feature
+++ b/tests/behat/quiz.feature
@@ -148,8 +148,8 @@ Feature: Usage in quiz
Scenario: Try to answer a question with two parts, one drowdown multiple choice in each of them
When I follow "Quiz 8"
And I press "Attempt quiz"
- And I set the field "Answer field 1 for part 1" to "Cat"
- And I set the field "Answer field 1 for part 2" to "Blue"
+ And I set the field "Answer for part 1" to "Cat"
+ And I set the field "Answer for part 2" to "Blue"
And I press "Finish attempt"
And I press "Submit all and finish"
# And I confirm the quiz submission in the modal dialog
diff --git a/tests/helper.php b/tests/helper.php
index ecaa2b7f..1ad1bcc3 100644
--- a/tests/helper.php
+++ b/tests/helper.php
@@ -98,7 +98,7 @@ protected static function make_a_formulas_question() {
*
* @return qtype_formulas_part
*/
- protected static function make_a_formulas_part(): qtype_formulas_part {
+ public static function make_a_formulas_part(): qtype_formulas_part {
question_bank::load_question_definition_classes('formulas');
$p = new qtype_formulas_part();
@@ -1744,6 +1744,7 @@ public static function make_formulas_question_testmcetwoparts(): qtype_formulas_
$q->parts[0] = $p1;
$p2 = self::make_a_formulas_part();
$p2->id = 15;
+ $p2->partindex = 1;
$p2->answermark = 1;
$p2->answer = '1';
$p2->answernotunique = '1';
@@ -1845,6 +1846,7 @@ public static function make_formulas_question_testmctwoparts(): qtype_formulas_q
$q->parts[0] = $p1;
$p2 = self::make_a_formulas_part();
$p2->id = 15;
+ $p2->partindex = 1;
$p2->answermark = 1;
$p2->answer = '1';
$p2->answernotunique = '1';
diff --git a/tests/question_test.php b/tests/question_test.php
index ae5c3c1f..de30e687 100644
--- a/tests/question_test.php
+++ b/tests/question_test.php
@@ -47,7 +47,7 @@
* @covers \qtype_formulas_part
* @covers \qtype_formulas
*/
-final class question_test extends \basic_testcase {
+final class question_test extends \advanced_testcase {
/**
* Create a question object of a certain type, as defined in the helper.php file.
@@ -476,6 +476,30 @@ public function test_get_question_summary_threeparts(): void {
$q->get_question_summary());
}
+ public function test_translate_mc_answers_for_feedback(): void {
+ $q = $this->get_test_formulas_question('testsinglenum');
+
+ // Change the part's text to use a multi-choice answer field.
+ $q->varsglobal = 'options = ["foo", "bar"]';
+ $q->parts[0]->subqtext = '{_0:options}';
+
+ // Start the attempt (to initialize everything) and check the translation works.
+ $q->start_attempt(new question_attempt_step(), 1);
+ $translation = $q->parts[0]->translate_mc_answers_for_feedback(['0_0' => '1']);
+ self::assertArrayHasKey('0_0', $translation);
+ self::assertEquals('bar', $translation['0_0']);
+
+ // Change the variable reference to a bad name and check whether the error is caught.
+ $q->parts[0]->subqtext = '{_0:xxx}';
+ $q->start_attempt(new question_attempt_step(), 1);
+ $translation = $q->parts[0]->translate_mc_answers_for_feedback(['0_0' => '1']);
+ self::assertDebuggingCalled(
+ 'Could not translate multiple choice index back to its value. This should not happen. Please file a bug report.'
+ );
+ self::assertArrayHasKey('0_0', $translation);
+ self::assertEquals('1', $translation['0_0']);
+ }
+
public function test_get_question_summary_test2(): void {
$q = $this->get_test_formulas_question('test4');
$q->start_attempt(new question_attempt_step(), 1);
diff --git a/tests/renderer_test.php b/tests/renderer_test.php
index d23a34e3..e5eee629 100644
--- a/tests/renderer_test.php
+++ b/tests/renderer_test.php
@@ -19,6 +19,7 @@
use question_hint_with_parts;
use question_state;
use test_question_maker;
+use qtype_formulas;
use qtype_formulas_test_helper;
use Generator;
@@ -311,26 +312,42 @@ public function test_render_mc_question(): void {
$this->get_contains_num_parts_correct(1)
);
$this->check_current_mark(0.7);
+
+ // Restart with immediate feedback to check the radio box is disabled when showing the feedback.
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->process_submission(['0_0' => '1', '-submit' => 1]);
+ $this->check_current_output(
+ $this->get_contains_radio_expectation(['id' => $this->quba->get_field_prefix($this->slot) . '0_0_0'], false, false),
+ $this->get_contains_radio_expectation(['id' => $this->quba->get_field_prefix($this->slot) . '0_0_1'], false, true),
+ $this->get_contains_radio_expectation(['id' => $this->quba->get_field_prefix($this->slot) . '0_0_2'], false, false),
+ $this->get_contains_radio_expectation(['id' => $this->quba->get_field_prefix($this->slot) . '0_0_3'], false, false),
+ );
}
public function test_render_mce_question(): void {
- // Create a single part multiple choice (radio) question.
+ // Create a single part multiple choice (dropdown) question.
$q = $this->get_test_formulas_question('testmce');
$this->start_attempt_at_question($q, 'immediatefeedback', 1);
$this->render();
- $this->check_output_contains_selectoptions(
- $this->get_contains_select_expectation('0_0', ['Dog', 'Cat', 'Bird', 'Fish'])
- );
+ // Using check_current_output to make sure that the is actually there. Using
+ // check_output_contains_selectoptions only makes sure that the options are there
+ // *among* existing s; if no is there, the options do not need to exist.
$this->check_current_output(
- $this->get_does_not_contain_specific_feedback_expectation()
+ new \question_contains_tag_with_attribute('select', 'name', $this->quba->get_field_prefix($this->slot) . '0_0'),
+ $this->get_does_not_contain_specific_feedback_expectation(),
+ new \question_contains_tag_with_contents('label', 'Answer'),
+ new \question_contains_tag_with_attribute('label', 'class', 'subq accesshide'),
+ );
+ $this->check_output_contains_selectoptions(
+ $this->get_contains_select_expectation('0_0', ['Dog', 'Cat', 'Bird', 'Fish'], 0)
);
// Submit wrong answer.
$this->process_submission(['0_0' => '0', '-submit' => 1]);
$this->check_current_state(question_state::$gradedwrong);
$this->check_output_contains_selectoptions(
- $this->get_contains_select_expectation('0_0', ['Dog', 'Cat', 'Bird', 'Fish'], 0)
+ $this->get_contains_select_expectation('0_0', ['Dog', 'Cat', 'Bird', 'Fish'], 0)
);
$this->check_current_mark(0);
$this->check_output_contains_lang_string('correctansweris', 'qtype_formulas', 'Cat');
@@ -340,12 +357,178 @@ public function test_render_mce_question(): void {
$this->process_submission(['0_0' => '1', '-submit' => 1]);
$this->check_current_state(question_state::$gradedright);
$this->check_output_contains_selectoptions(
- $this->get_contains_select_expectation('0_0', ['Dog', 'Cat', 'Bird', 'Fish'], 1)
+ $this->get_contains_select_expectation('0_0', ['Dog', 'Cat', 'Bird', 'Fish'], 1, false)
);
$this->check_current_mark(1);
$this->check_output_contains_lang_string('correctansweris', 'qtype_formulas', 'Cat');
}
+ public function test_render_mc_question_with_missing_options(): void {
+ // Create a single part multiple choice (dropdown) question.
+ $q = $this->get_test_formulas_question('testmce');
+ $q->parts[0]->subqtext = '{_0:xxxx}';
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+
+ $this->render();
+ // The options are not available, so a text box should be rendered instead.
+ $this->check_output_contains_text_input('0_0', '', true);
+ $this->check_current_output(
+ new \question_contains_tag_with_contents('label', 'Answer'),
+ );
+
+ // Create a single part multiple choice (radio) question.
+ $q = $this->get_test_formulas_question('testmc');
+ $q->parts[0]->subqtext = '{_0:xxxx}';
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+
+ $this->render();
+ // The options are not available, so a text box should be rendered instead.
+ $this->check_output_contains_text_input('0_0', '', true);
+ $this->check_current_output(
+ new \question_contains_tag_with_contents('label', 'Answer'),
+ );
+ }
+
+ public function test_render_mce_accessibility_labels(): void {
+ // Create a single part multiple choice (dropdown) question.
+ $q = $this->get_test_formulas_question('testmce');
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_contents('label', 'Answer'),
+ );
+
+ // Create a single part multiple choice (dropdown) question with an additional field.
+ $q = $this->get_test_formulas_question('testmce');
+ $q->parts[0]->numbox = 2;
+ $q->parts[0]->answer = '[1, 1]';
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_contents('label', 'Answer field 1'),
+ new \question_contains_tag_with_contents('label', 'Answer field 2'),
+ );
+
+ // Create a two-part multiple choice (dropdown) question with an additional field in each part.
+ $q = $this->get_test_formulas_question('testmcetwoparts');
+ $q->parts[0]->numbox = 2;
+ $q->parts[0]->answer = '[1, 1]';
+ $q->parts[1]->numbox = 2;
+ $q->parts[1]->answer = '[1, 1]';
+
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_contents('label', 'Answer field 1 for part 1'),
+ new \question_contains_tag_with_contents('label', 'Answer field 2 for part 1'),
+ new \question_contains_tag_with_contents('label', 'Answer field 1 for part 2'),
+ new \question_contains_tag_with_contents('label', 'Answer field 2 for part 2'),
+ );
+ }
+
+ public function test_textbox_tooltip_title(): void {
+ // Create a simple test question.
+ $q = $this->get_test_formulas_question('testsinglenum');
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_attribute('input', 'data-title', 'Number'),
+ );
+
+ // Change answer type to numeric.
+ $q->parts[0]->answertype = qtype_formulas::ANSWER_TYPE_NUMERIC;
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_attribute('input', 'data-title', 'Numeric'),
+ );
+
+ // Change answer type to numerical formula.
+ $q->parts[0]->answertype = qtype_formulas::ANSWER_TYPE_NUMERICAL_FORMULA;
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_attribute('input', 'data-title', 'Numerical formula'),
+ );
+
+ // Change answer type to algebraic formula.
+ $q->parts[0]->answertype = qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_attribute('input', 'data-title', 'Algebraic formula'),
+ );
+
+ // Create a simple test question with a combined field.
+ $q = $this->get_test_formulas_question('testsinglenumunit');
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_attribute('input', 'data-title', 'Number and unit'),
+ );
+
+ // Create a simple test question with a separate unit field.
+ $q = $this->get_test_formulas_question('testsinglenumunitsep');
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_attribute('input', 'data-title', 'Number'),
+ new \question_contains_tag_with_attribute('input', 'data-title', 'Unit'),
+ );
+ }
+
+ public function test_render_mc_accessibility_labels(): void {
+ // Create a single part multiple choice (radio) question.
+ $q = $this->get_test_formulas_question('testmc');
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_attribute('legend', 'class', 'sr-only'),
+ new \question_contains_tag_with_attribute('span', 'class', 'sr-only'),
+ new \question_contains_tag_with_contents('span', 'Answer'),
+ );
+
+ // Create a single part multiple choice (radio) question with an additional field.
+ $q = $this->get_test_formulas_question('testmc');
+ $q->parts[0]->numbox = 2;
+ $q->parts[0]->answer = '[1, 1]';
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_attribute('legend', 'class', 'sr-only'),
+ new \question_contains_tag_with_contents('span', 'Answer field 1'),
+ new \question_contains_tag_with_contents('label', 'Answer field 2'),
+ );
+
+ // Create a two-part multiple choice (radio) question with an additional field in each part.
+ $q = $this->get_test_formulas_question('testmctwoparts');
+ $q->parts[0]->numbox = 2;
+ $q->parts[0]->answer = '[1, 1]';
+ $q->parts[1]->numbox = 2;
+ $q->parts[1]->answer = '[1, 1]';
+
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_attribute('legend', 'class', 'sr-only'),
+ new \question_contains_tag_with_contents('span', 'Answer field 1 for part 1'),
+ new \question_contains_tag_with_contents('label', 'Answer field 2 for part 1'),
+ new \question_contains_tag_with_contents('span', 'Answer field 1 for part 2'),
+ new \question_contains_tag_with_contents('label', 'Answer field 2 for part 2'),
+ );
+ }
+
+ public function test_render_textbox_accessibility_labels(): void {
+ // Create a multi-part question with a combined and a separate unit field.
+ $q = $this->get_test_formulas_question('testmethodsinparts');
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_contents('label', 'Answer and unit for part 1'),
+ new \question_contains_tag_with_contents('label', 'Answer for part 2'),
+ new \question_contains_tag_with_contents('label', 'Unit for part 2'),
+ new \question_contains_tag_with_contents('label', 'Answer for part 3'),
+ new \question_contains_tag_with_contents('label', 'Answer for part 4'),
+ );
+
+ // Create a multi-part question with a combined and a separate unit field.
+ $q = $this->get_test_formulas_question('testtwoandtwo');
+ $this->start_attempt_at_question($q, 'immediatefeedback', 1);
+ $this->check_current_output(
+ new \question_contains_tag_with_contents('label', 'Answer field 1 for part 1'),
+ new \question_contains_tag_with_contents('label', 'Answer field 2 for part 1'),
+ new \question_contains_tag_with_contents('label', 'Answer field 1 for part 2'),
+ new \question_contains_tag_with_contents('label', 'Answer field 2 for part 2'),
+ );
+ }
+
public function test_question_with_hint(): void {
// Create a single part multiple choice (radio) question.
$q = $this->get_test_formulas_question('testsinglenum');