• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

FormulasQuestion / moodle-qtype_formulas / 17064626124

19 Aug 2025 08:54AM UTC coverage: 97.546% (-0.09%) from 97.633%
17064626124

push

github

web-flow
improve UX in validation of student answers (#269)

4 of 9 new or added lines in 3 files covered. (44.44%)

4333 of 4442 relevant lines covered (97.55%)

1680.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.78
/renderer.php
1
<?php
2
// This file is part of Moodle - https://moodle.org/
3
//
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.
8
//
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.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <https://www.gnu.org/licenses/>.
16

17
/**
18
 * Formulas question renderer class.
19
 *
20
 * @package    qtype_formulas
21
 * @copyright  2009 The Open University
22
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24

25
/**
26
 * Base class for generating the bits of output for formulas questions.
27
 *
28
 * @copyright  2009 The Open University
29
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
30
 */
31
class qtype_formulas_renderer extends qtype_with_combined_feedback_renderer {
32

33
    /** @var string */
34
    const UNIT_FIELD = 'u';
35

36
    /** @var string */
37
    const COMBINED_FIELD = '';
38

39
    /**
40
     * Generate the display of the formulation part of the question. This is the area that
41
     * contains the question text and the controls for students to input their answers.
42
     * Once the question is answered, it will contain the green tick or the red cross and
43
     * the part's general / combined feedback.
44
     *
45
     * @param question_attempt $qa the question attempt to display.
46
     * @param question_display_options $options controls what should and should not be displayed.
47
     * @return ?string HTML fragment.
48
     */
49
    public function formulation_and_controls(question_attempt $qa, question_display_options $options): ?string {
50
        // First, fetch the instantiated question from the attempt.
51
        /** @var qtype_formulas_question $question */
52
        $question = $qa->get_question();
2,520✔
53

54
        if (count($question->textfragments) !== $question->numparts + 1) {
2,520✔
55
            $this->output->notification(get_string('error_question_damaged', 'qtype_formulas'), 'error');
×
56
            return null;
×
57
        }
58

59
        $questiontext = '';
2,520✔
60
        // First, iterate over all parts, put the corresponding fragment of the main question text at the
61
        // right position, followed by the part's text, input and (if applicable) feedback elements.
62
        foreach ($question->parts as $part) {
2,520✔
63
            $questiontext .= $question->format_text(
2,520✔
64
                $question->textfragments[$part->partindex], $question->questiontextformat,
2,520✔
65
                $qa, 'question', 'questiontext', $question->id, false
2,520✔
66
            );
2,520✔
67
            $questiontext .= $this->part_formulation_and_controls($qa, $options, $part);
2,520✔
68
        }
69
        // All parts are done. We now append the final fragment of the main question text. Note that this fragment
70
        // might be empty.
71
        $questiontext .= $question->format_text(
2,520✔
72
            end($question->textfragments), $question->questiontextformat, $qa, 'question', 'questiontext', $question->id, false
2,520✔
73
        );
2,520✔
74

75
        // Pack everything in a <div> and, if the question is in an invalid state, append the appropriate error message
76
        // at the very end.
77
        $result = html_writer::tag('div', $questiontext, ['class' => 'qtext']);
2,520✔
78
        if ($qa->get_state() == question_state::$invalid) {
2,520✔
79
            $result .= html_writer::nonempty_tag(
42✔
80
                'div',
42✔
81
                $question->get_validation_error($qa->get_last_qt_data()),
42✔
82
                ['class' => 'validationerror']
42✔
83
            );
42✔
84
        }
85

86
        return $result;
2,520✔
87
    }
88

89
    /**
90
     * Return HTML that needs to be included in the page's <head> when this
91
     * question is used.
92
     *
93
     * @param question_attempt $qa question attempt that will be displayed on the page
94
     * @return string HTML fragment
95
     */
96
    public function head_code(question_attempt $qa): string {
97
        global $CFG;
NEW
98
        $this->page->requires->js_call_amd(
×
NEW
99
            'qtype_formulas/answervalidation',
×
NEW
100
            'init',
×
NEW
101
            [get_config('qtype_formulas', 'debouncedelay')]
×
NEW
102
        );
×
103

104
        // Include backwards-compatibility layer for Bootstrap 4 data attributes, if available.
105
        // We may safely assume that if the uncompiled version is there, the minified one exists as well.
106
        if (file_exists($CFG->dirroot . '/theme/boost/amd/src/bs4-compat.js')) {
×
107
            $this->page->requires->js_call_amd('theme_boost/bs4-compat', 'init');
×
108
        }
109

110
        return '';
×
111
    }
112

113
    /**
114
     * Return the part text, controls, grading details and feedbacks.
115
     *
116
     * @param question_attempt $qa question attempt that will be displayed on the page
117
     * @param question_display_options $options controls what should and should not be displayed
118
     * @param qtype_formulas_part $part question part
119
     * @return void
120
     */
121
    public function part_formulation_and_controls(question_attempt $qa, question_display_options $options,
122
            qtype_formulas_part $part): string {
123

124
        // The behaviour might change the display options per part, so it is safer to clone them here.
125
        $partoptions = clone $options;
2,520✔
126
        if ($qa->get_behaviour_name() === 'adaptivemultipart') {
2,520✔
127
            $qa->get_behaviour()->adjust_display_options_for_part($part->partindex, $partoptions);
105✔
128
        }
129

130
        // Fetch information about the outcome: grade, feedback symbol, CSS class to be used.
131
        $outcomedata = $this->get_part_feedback_class_and_symbol($qa, $partoptions, $part);
2,520✔
132

133
        // First of all, we take the part's question text and its input fields.
134
        $output = $this->get_part_formulation($qa, $partoptions, $part, $outcomedata);
2,520✔
135

136
        // If the user has requested the feedback symbol to be placed at a special position, we
137
        // do that now. Otherwise, we just append it after the part's text and input boxes.
138
        if (strpos($output, '{_m}') !== false) {
2,520✔
139
            $output = str_replace('{_m}', $outcomedata->feedbacksymbol, $output);
×
140
        } else {
141
            $output .= $outcomedata->feedbacksymbol;
2,520✔
142
        }
143

144
        // The part's feedback consists of the combined feedback (correct, partially correct, incorrect -- depending on the
145
        // outcome) and the general feedback which is given in all cases.
146
        $feedback = $this->part_combined_feedback($qa, $partoptions, $part, $outcomedata->fraction);
2,520✔
147
        $feedback .= $this->part_general_feedback($qa, $partoptions, $part);
2,520✔
148

149
        // If requested, the correct answer should be appended to the feedback.
150
        if ($partoptions->rightanswer) {
2,520✔
151
            $feedback .= $this->part_correct_response($part);
378✔
152
        }
153

154
        // Put all feedback into a <div> with the appropriate CSS class and append it to the output.
155
        $output .= html_writer::nonempty_tag('div', $feedback, ['class' => 'formulaspartoutcome outcome']);
2,520✔
156

157
        return html_writer::tag('div', $output , ['class' => 'formulaspart']);
2,520✔
158
    }
159

160
    /**
161
     * Return class and symbol for the part feedback.
162
     *
163
     * @param question_attempt $qa question attempt that will be displayed on the page
164
     * @param question_display_options $options controls what should and should not be displayed
165
     * @param qtype_formulas_part $part question part
166
     * @return stdClass
167
     */
168
    public function get_part_feedback_class_and_symbol(question_attempt $qa, question_display_options $options,
169
            qtype_formulas_part $part): stdClass {
170
        // Prepare a new object to hold the different elements.
171
        $result = new stdClass;
2,520✔
172

173
        // Fetch the last response data and grade it.
174
        $response = $qa->get_last_qt_data();
2,520✔
175
        list('answer' => $answergrade, 'unit' => $unitcorrect) = $part->grade($response);
2,520✔
176

177
        // The fraction will later be used to determine which feedback (correct, partially correct or incorrect)
178
        // to use. We have to take into account a possible deduction for a wrong unit.
179
        $result->fraction = $answergrade;
2,520✔
180
        if ($unitcorrect === false) {
2,520✔
181
            $result->fraction *= (1 - $part->unitpenalty);
1,533✔
182
        }
183

184
        // By default, we add no feedback at all...
185
        $result->feedbacksymbol = '';
2,520✔
186
        $result->feedbackclass = '';
2,520✔
187
        // ... unless correctness is requested in the display options.
188
        if ($options->correctness) {
2,520✔
189
            $result->feedbacksymbol = $this->feedback_image($result->fraction);
462✔
190
            $result->feedbackclass = $this->feedback_class($result->fraction);
462✔
191
        }
192
        return $result;
2,520✔
193
    }
194

195
    /**
196
     * Format given number according to numbering style, e. g. abc or 123.
197
     *
198
     * @param int $num number
199
     * @param string $style style to render the number in, acccording to {@see qtype_multichoice::get_numbering_styles()}
200
     * @return string number $num in the requested style
201
     */
202
    protected static function number_in_style(int $num, string $style): string {
203
        switch ($style) {
204
            case 'abc':
63✔
205
                $number = chr(ord('a') + $num);
42✔
206
                break;
42✔
207
            case 'ABCD':
21✔
208
                $number = chr(ord('A') + $num);
×
209
                break;
×
210
            case '123':
21✔
211
                $number = $num + 1;
×
212
                break;
×
213
            case 'iii':
21✔
214
                $number = question_utils::int_to_roman($num + 1);
×
215
                break;
×
216
            case 'IIII':
21✔
217
                $number = strtoupper(question_utils::int_to_roman($num + 1));
×
218
                break;
×
219
            case 'none':
21✔
220
                return '';
×
221
            default:
222
                // Default similar to none for compatibility with old questions.
223
                return '';
21✔
224
        }
225
        return $number . '. ';
42✔
226
    }
227

228
    /**
229
     * Create a set of radio boxes for a multiple choice answer input.
230
     *
231
     * @param qtype_formulas_part $part question part
232
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
233
     * @param question_attempt $qa question attempt that will be displayed on the page
234
     * @param array $answeroptions array of strings containing the answer options to choose from
235
     * @param bool $shuffle whether the options should be shuffled
236
     * @param question_display_options $displayoptions controls what should and should not be displayed
237
     * @param string $feedbackclass
238
     * @return string HTML fragment
239
     */
240
    protected function create_radio_mc_answer(qtype_formulas_part $part, $answerindex, question_attempt $qa,
241
            array $answeroptions, bool $shuffle, question_display_options $displayoptions, string $feedbackclass = ''): string {
242
        /** @var qype_formulas_question $question */
243
        $question = $qa->get_question();
63✔
244

245
        $variablename = "{$part->partindex}_{$answerindex}";
63✔
246
        $currentanswer = $qa->get_last_qt_var($variablename);
63✔
247
        $inputname = $qa->get_qt_field_name($variablename);
63✔
248

249
        $inputattributes['type'] = 'radio';
63✔
250
        $inputattributes['name'] = $inputname;
63✔
251
        if ($displayoptions->readonly) {
63✔
252
            $inputattributes['disabled'] = 'disabled';
21✔
253
        }
254

255
        // First, we open a <fieldset> around the entire group of options.
256
        $output = html_writer::start_tag('fieldset', ['class' => 'multichoice_answer']);
63✔
257

258
        // Inside the fieldset, we put the accessibility label, following the example of core's multichoice
259
        // question type, i. e. the label is inside a <span> with class 'sr-only', wrapped in a <legend>.
260
        $output .= html_writer::start_tag('legend', ['class' => 'sr-only']);
63✔
261
        $output .= html_writer::span(
63✔
262
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
63✔
263
            'sr-only'
63✔
264
        );
63✔
265
        $output .= html_writer::end_tag('legend');
63✔
266

267
        // If needed, shuffle the options while maintaining the keys.
268
        if ($shuffle) {
63✔
269
            $keys = array_keys($answeroptions);
21✔
270
            shuffle($keys);
21✔
271

272
            $shuffledoptions = [];
21✔
273
            foreach ($keys as $key) {
21✔
274
                $shuffledoptions[$key] = $answeroptions[$key];
21✔
275
            }
276
            $answeroptions = $shuffledoptions;
21✔
277
        }
278

279
        // Iterate over all options.
280
        foreach ($answeroptions as $i => $optiontext) {
63✔
281
            $numbering = html_writer::span(self::number_in_style($i, $question->answernumbering), 'answernumber');
63✔
282
            $labeltext = $question->format_text(
63✔
283
                $numbering . $optiontext, $part->subqtextformat , $qa, 'qtype_formulas', 'answersubqtext', $part->id, false
63✔
284
            );
63✔
285

286
            $inputattributes['id'] = $inputname . '_' . $i;
63✔
287
            $inputattributes['value'] = $i;
63✔
288
            // Class ml-3 is Bootstrap's class for margin-left: 1rem; it used to be m-l-1.
289
            $label = $this->create_label_for_input($labeltext, $inputattributes['id'], ['class' => 'ml-3']);
63✔
290
            $inputattributes['aria-labelledby'] = $label['id'];
63✔
291

292
            // We must make sure $currentanswer is not null, because otherwise the first radio box
293
            // might be selected if there is no answer at all. It seems better to avoid strict equality,
294
            // because we might compare a string to a number.
295
            $isselected = ($i == $currentanswer && !is_null($currentanswer));
63✔
296

297
            // We do not reset the $inputattributes array on each iteration, so we have to add/remove the
298
            // attribute every time.
299
            if ($isselected) {
63✔
300
                $inputattributes['checked'] = 'checked';
21✔
301
            } else {
302
                unset($inputattributes['checked']);
63✔
303
            }
304

305
            // Each option (radio box element plus label) is wrapped in its own <div> element.
306
            $divclass = 'r' . ($i % 2);
63✔
307
            if ($displayoptions->correctness && $isselected) {
63✔
308
                $divclass .= ' ' . $feedbackclass;
21✔
309
            }
310
            $output .= html_writer::start_div($divclass);
63✔
311

312
            // Now add the <input> tag and its <label>.
313
            $output .= html_writer::empty_tag('input', $inputattributes);
63✔
314
            $output .= $label['html'];
63✔
315

316
            // Close the option's <div>.
317
            $output .= html_writer::end_div();
63✔
318
        }
319

320
        // Close the option group's <fieldset>.
321
        $output .= html_writer::end_tag('fieldset');
63✔
322

323
        return $output;
63✔
324
    }
325

326
    /**
327
     * Translate an array containing formatting options into a CSS format string, e. g. from
328
     * ['w' => '50px', 'bgcol' => 'yellow'] to 'width: 50px; background-color: yellow'. Note:
329
     * - colors can be defined in 3 or 6 digit hex RGB, in 4 or 8 digit hex RGBA or as CSS named color
330
     * - widths can be defined as a number followed by the units px, rem or em; if the unit is omitted, rem will be used
331
     * - alignment can be defined as left, right, center, start or end
332
     *
333
     * @param array $options associative array containing options (in our own denomination) and their settings
334
     * @return string
335
     */
336
    protected function get_css_properties(array $options): string {
337
        // Define some regex pattern.
338
        $hexcolor = '#([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{3}|[0-9A-F]{4})';
2,436✔
339
        $namedcolor = '[A-Z]+';
2,436✔
340
        // We accept floating point numbers with or without a leading integer part and integers.
341
        // Floating point numbers with a trailing decimal point do not work in all browsers.
342
        $length = '(\d+\.\d+|\d*\.\d+|\d+)(px|em|rem)?';
2,436✔
343
        $alignment = 'start|end|left|right|center';
2,436✔
344

345
        $styles = [];
2,436✔
346
        foreach ($options as $name => $value) {
2,436✔
347
            switch ($name) {
348
                case 'bgcol':
2,436✔
349
                    if (!preg_match("/^(($hexcolor)|($namedcolor))$/i", $value)) {
609✔
350
                        break;
42✔
351
                    }
352
                    $styles[] = "background-color: $value";
567✔
353
                    break;
567✔
354
                case 'txtcol':
2,436✔
355
                    if (!preg_match("/^(($hexcolor)|($namedcolor))$/i", $value)) {
252✔
356
                        break;
42✔
357
                    }
358
                    $styles[] = "color: $value";
210✔
359
                    break;
210✔
360
                case 'w':
2,436✔
361
                    if (!preg_match("/^($length)$/i", $value)) {
2,436✔
362
                        break;
168✔
363
                    }
364
                    // If no unit is given, append rem.
365
                    $styles[] = "width: $value" . (preg_match('/\d$/', $value) ? 'rem' : '');
2,352✔
366
                    break;
2,352✔
367
                case 'align':
273✔
368
                    if (!preg_match("/^($alignment)$/i", $value)) {
273✔
369
                        break;
42✔
370
                    }
371
                    $styles[] = "text-align: $value";
231✔
372
                    break;
231✔
373
            }
374
        }
375

376
        return implode(';', $styles);
2,436✔
377
    }
378

379
    /**
380
     * Create a <label> element for a given input control (e. g. a text field). Returns the
381
     * HTML and the label's ID.
382
     *
383
     * @param string $text the label's text
384
     * @param string $inputid ID of the input control for which the label is created
385
     * @param array $additionalattributes possibility to add custom attributes, attribute name => value
386
     * @return array 'id' => label's ID to be used in 'aria-labelledby' attribute, 'html' => HTML code
387
     */
388
    protected function create_label_for_input(string $text, string $inputid, array $additionalattributes = []): array {
389
        $labelid = 'lbl_' . str_replace(':', '__', $inputid);
2,520✔
390
        $attributes = [
2,520✔
391
            'class' => 'subq accesshide',
2,520✔
392
            'for' => $inputid,
2,520✔
393
            'id' => $labelid,
2,520✔
394
        ];
2,520✔
395

396
        // Merging the additional attributes with the default attributes; left array has precedence when
397
        // using the + operator.
398
        $attributes = $additionalattributes + $attributes;
2,520✔
399

400
        return [
2,520✔
401
            'id' => $labelid,
2,520✔
402
            'html' => html_writer::tag('label', $text, $attributes),
2,520✔
403
        ];
2,520✔
404
    }
405

406
    /**
407
     * Create a <select> field for a multiple choice answer input.
408
     *
409
     * @param qtype_formulas_part $part question part
410
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
411
     * @param question_attempt $qa question attempt that will be displayed on the page
412
     * @param array $answeroptions array of strings containing the answer options to choose from
413
     * @param bool $shuffle whether the options should be shuffled
414
     * @param question_display_options $displayoptions controls what should and should not be displayed
415
     * @return string HTML fragment
416
     */
417
    protected function create_dropdown_mc_answer(qtype_formulas_part $part, $answerindex, question_attempt $qa,
418
            array $answeroptions, bool $shuffle, question_display_options $displayoptions): string {
419
        /** @var qype_formulas_question $question */
420
        $question = $qa->get_question();
63✔
421

422
        $variablename = "{$part->partindex}_{$answerindex}";
63✔
423
        $currentanswer = $qa->get_last_qt_var($variablename);
63✔
424
        $inputname = $qa->get_qt_field_name($variablename);
63✔
425

426
        $inputattributes['name'] = $inputname;
63✔
427
        $inputattributes['value'] = $currentanswer;
63✔
428
        $inputattributes['id'] = $inputname;
63✔
429
        $inputattributes['class'] = 'formulas-select';
63✔
430

431
        $label = $this->create_label_for_input(
63✔
432
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
63✔
433
            $inputname
63✔
434
        );
63✔
435
        $inputattributes['aria-labelledby'] = $label['id'];
63✔
436

437
        if ($displayoptions->readonly) {
63✔
438
            $inputattributes['disabled'] = 'disabled';
21✔
439
        }
440

441
        // First, we open a <span> around the dropdown field and its accessibility label.
442
        $output = html_writer::start_tag('span', ['class' => 'formulas_menu']);
63✔
443
        $output .= $label['html'];
63✔
444

445
        // Iterate over all options.
446
        $entries = [];
63✔
447
        foreach ($answeroptions as $optiontext) {
63✔
448
            $entries[] = $question->format_text(
63✔
449
                $optiontext, $part->subqtextformat , $qa, 'qtype_formulas', 'answersubqtext', $part->id, false
63✔
450
            );
63✔
451
        }
452

453
        // If needed, shuffle the options while maintaining the keys.
454
        if ($shuffle) {
63✔
455
            $keys = array_keys($entries);
21✔
456
            shuffle($keys);
21✔
457

458
            $shuffledentries = [];
21✔
459
            foreach ($keys as $key) {
21✔
460
                $shuffledentries[$key] = $entries[$key];
21✔
461
            }
462
            $entries = $shuffledentries;
21✔
463
        }
464

465
        $output .= html_writer::select($entries, $inputname, $currentanswer, ['' => ''], $inputattributes);
63✔
466
        $output .= html_writer::end_tag('span');
63✔
467

468
        return $output;
63✔
469
    }
470

471
    /**
472
     * Generate the right label for the input control, depending on the number of answers in the given part
473
     * and the number of parts in the question. Special cases (combined field, unit field) are also taken
474
     * into account. Returns the appropriate string from the language file. Examples are "Answer field for
475
     * part X", "Answer field X for part Y" or "Answer and unit for part X".
476
     *
477
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
478
     * @param int $totalanswers number of answers for the given part
479
     * @param int $partindex number of the part (starting at 0) in this question
480
     * @param int $totalparts number of parts in the question
481
     * @return string localized string
482
     */
483
    protected function generate_accessibility_label_text($answerindex, int $totalanswers, int $partindex,
484
            int $totalparts): string {
485

486
        // Some language strings need parameters.
487
        $labeldata = new stdClass();
2,520✔
488

489
        // The language strings start with 'answerunit' for a separate unit field, 'answercombinedunit' for
490
        // a combined field, 'answercoordinate' for an answer field when there are multiple answers in the
491
        // part or just 'answer' if there is a single field.
492
        $labelstring = 'answer';
2,520✔
493
        if ($answerindex === self::UNIT_FIELD) {
2,520✔
494
            $labelstring .= 'unit';
777✔
495
        } else if ($answerindex === self::COMBINED_FIELD) {
2,520✔
496
            $labelstring .= 'combinedunit';
840✔
497
        } else if ($totalanswers > 1) {
2,079✔
498
            $labelstring .= 'coordinate';
63✔
499
            $labeldata->numanswer = $answerindex + 1;
63✔
500
        }
501

502
        // The language strings end with 'multi' for multi-part questions or 'single' for single-part
503
        // questions.
504
        if ($totalparts > 1) {
2,520✔
505
            $labelstring .= 'multi';
105✔
506
            $labeldata->part = $partindex + 1;
105✔
507
        } else {
508
            $labelstring .= 'single';
2,457✔
509
        }
510

511
        return get_string($labelstring, 'qtype_formulas', $labeldata);
2,520✔
512
    }
513

514
    /**
515
     * Create an <input> field.
516
     *
517
     * @param qtype_formulas_part $part question part
518
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
519
     * @param question_attempt $qa question attempt that will be displayed on the page
520
     * @param question_display_options $displayoptions controls what should and should not be displayed
521
     * @param array $formatoptions associative array 'optionname' => 'value', e. g. 'w' => '50px'
522
     * @param string $feedbackclass
523
     * @return string HTML fragment
524
     */
525
    protected function create_input_box(qtype_formulas_part $part, $answerindex,
526
            question_attempt $qa, question_display_options $displayoptions, array $formatoptions = [],
527
            string $feedbackclass = ''): string {
528
        /** @var qype_formulas_question $question */
529
        $question = $qa->get_question();
2,436✔
530

531
        // The variable name will be N_ for the (single) combined unit field of part N,
532
        // or N_M for answer #M in part #N. If #M is equal to the part's numbox (i. e. the
533
        // number of answers), it is a unit field; note that the fields are numbered starting
534
        // from 0, so with 3 answers, we have N_0, N_1, N_2 and only use N_3 if there is a
535
        // unit.
536
        $variablename = $part->partindex . '_';
2,436✔
537
        if ($answerindex === self::UNIT_FIELD) {
2,436✔
538
            $variablename .= $part->numbox;
777✔
539
        } else {
540
            $variablename .= ($answerindex === self::COMBINED_FIELD ? '' : $answerindex);
2,436✔
541
        }
542

543
        $currentanswer = $qa->get_last_qt_var($variablename);
2,436✔
544
        $inputname = $qa->get_qt_field_name($variablename);
2,436✔
545

546
        // Text fields will have a tooltip attached. The tooltip's content depends on the
547
        // answer type. Special tooltips exist for combined or separate unit fields.
548
        switch ($part->answertype) {
2,436✔
549
            case qtype_formulas::ANSWER_TYPE_NUMERIC:
550
                $titlestring = 'numeric';
126✔
551
                break;
126✔
552
            case qtype_formulas::ANSWER_TYPE_NUMERICAL_FORMULA:
553
                $titlestring = 'numerical_formula';
126✔
554
                break;
126✔
555
            case qtype_formulas::ANSWER_TYPE_ALGEBRAIC:
556
                $titlestring = 'algebraic_formula';
168✔
557
                break;
168✔
558
            case qtype_formulas::ANSWER_TYPE_NUMBER:
559
            default:
560
                $titlestring = 'number';
2,100✔
561
        }
562
        if ($answerindex === self::COMBINED_FIELD) {
2,436✔
563
            $titlestring .= '_unit';
840✔
564
        }
565
        if ($answerindex === self::UNIT_FIELD) {
2,436✔
566
            $titlestring = 'unit';
777✔
567
        }
568
        $title = get_string($titlestring, 'qtype_formulas');
2,436✔
569

570
        // Fetch the configured default width and use it, if the setting exists. Otherwise,
571
        // the plugin's default as defined in styles.css will be used. If the user did not
572
        // specify a unit, we use pixels (px). Note that this is different from the renderer
573
        // where rem is used in order to allow for the short syntax 'w=3' (3 chars wide).
574
        $defaultformat = [];
2,436✔
575
        $defaultwidth = get_config('qtype_formulas', "defaultwidth_{$titlestring}");
2,436✔
576
        // If the default width has not been set for the current answer box type, $defaultwidth will
577
        // be false and thus not numeric.
578
        if (is_numeric($defaultwidth)) {
2,436✔
579
            $defaultwidthunit = get_config('qtype_formulas', "defaultwidthunit");
2,436✔
580
            if (!in_array($defaultwidthunit, ['px', 'rem', 'em'])) {
2,436✔
581
                $defaultwidthunit = 'px';
168✔
582
            }
583
            $defaultformat = ['w' => $defaultwidth . $defaultwidthunit];
2,436✔
584
        }
585
        // Using the union operator the values from the left array will be kept.
586
        $formatoptions = $formatoptions + $defaultformat;
2,436✔
587

588
        $inputattributes = [
2,436✔
589
            'type' => 'text',
2,436✔
590
            'name' => $inputname,
2,436✔
591
            'value' => $currentanswer,
2,436✔
592
            'id' => $inputname,
2,436✔
593
            'style' => $this->get_css_properties($formatoptions),
2,436✔
594

595
            'data-answertype' => ($answerindex === self::UNIT_FIELD ? 'unit' : $part->answertype),
2,436✔
596
            'data-withunit' => ($answerindex === self::COMBINED_FIELD ? '1' : '0'),
2,436✔
597

598
            'data-toggle' => 'tooltip',
2,436✔
599
            'data-title' => $title,
2,436✔
600
            'data-custom-class' => 'qtype_formulas-tooltip',
2,436✔
601
            'title' => $title,
2,436✔
602
            'class' => "form-control formulas_{$titlestring} {$feedbackclass}",
2,436✔
603
            'maxlength' => 128,
2,436✔
604
        ];
2,436✔
605

606
        if ($displayoptions->readonly) {
2,436✔
607
            $inputattributes['readonly'] = 'readonly';
357✔
608
        }
609

610
        $label = $this->create_label_for_input(
2,436✔
611
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
2,436✔
612
            $inputname
2,436✔
613
        );
2,436✔
614
        $inputattributes['aria-labelledby'] = $label['id'];
2,436✔
615

616
        $output = $label['html'];
2,436✔
617
        $output .= html_writer::empty_tag('input', $inputattributes);
2,436✔
618

619
        return $output;
2,436✔
620
    }
621

622
    /**
623
     * Return the part's text with variables replaced by their values.
624
     *
625
     * @param question_attempt $qa question attempt that will be displayed on the page
626
     * @param question_display_options $options controls what should and should not be displayed
627
     * @param qtype_formulas_part $part question part
628
     * @param stdClass $sub class and symbol for the part feedback
629
     * @return string HTML fragment
630
     */
631
    public function get_part_formulation(question_attempt $qa, question_display_options $options,
632
            qtype_formulas_part $part, stdClass $sub): string {
633
        /** @var qype_formulas_question $question */
634
        $question = $qa->get_question();
2,520✔
635

636
        // Clone the part's evaluator and remove special variables like _0 etc., because they must
637
        // not be substituted here; otherwise, we would lose input boxes.
638
        $evaluator = clone $part->evaluator;
2,520✔
639
        $evaluator->remove_special_vars();
2,520✔
640
        $text = $evaluator->substitute_variables_in_text($part->subqtext);
2,520✔
641

642
        $subqreplaced = $question->format_text(
2,520✔
643
            $text, $part->subqtextformat, $qa, 'qtype_formulas', 'answersubqtext', $part->id, false
2,520✔
644
        );
2,520✔
645

646
        // Get the set of defined placeholders and their options.
647
        $boxes = $part->scan_for_answer_boxes($subqreplaced);
2,520✔
648

649
        // Append missing placholders at the end of part. We do not put a space before the opening
650
        // or after the closing brace, in order to get {_0}{_u} for questions with one answer and
651
        // a unit. This makes sure that the question will receive a combined unit field.
652
        for ($i = 0; $i <= $part->numbox; $i++) {
2,520✔
653
            // If no unit has been set, we do not append the {_u} placeholder.
654
            if ($i == $part->numbox && empty($part->postunit)) {
2,520✔
655
                continue;
1,407✔
656
            }
657
            $placeholder = ($i == $part->numbox) ? '_u' : "_{$i}";
2,520✔
658
            // If the placeholder does not exist yet, we create it with default settings, i. e. no multi-choice
659
            // and no styling.
660
            if (!array_key_exists($placeholder, $boxes)) {
2,520✔
661
                $boxes[$placeholder] = [
609✔
662
                    'placeholder' => '{' . $placeholder . '}',
609✔
663
                    'options' => '',
609✔
664
                    'dropdown' => false,
609✔
665
                    'format' => [],
609✔
666
                ];
609✔
667
                $subqreplaced .= '{' . $placeholder . '}';
609✔
668
            }
669
        }
670

671
        // If part has combined unit answer input.
672
        if ($part->has_combined_unit_field()) {
2,520✔
673
            // For a combined unit field, we try to merge the formatting options from the {_0} and the
674
            // {_u} placeholder, giving precedence to the latter.
675
            $mergedformat = $boxes['_u']['format'] + $boxes['_0']['format'];
840✔
676
            $combinedfieldhtml = $this->create_input_box(
840✔
677
                $part, self::COMBINED_FIELD, $qa, $options, $mergedformat, $sub->feedbackclass
840✔
678
            );
840✔
679
            // The combined field must be placed where the user has the {_0}{_u} placeholders, possibly with
680
            // their formatting options.
681
            $boxplaceholders = $boxes['_0']['placeholder'] . $boxes['_u']['placeholder'];
840✔
682
            return str_replace($boxplaceholders, $combinedfieldhtml, $subqreplaced);
840✔
683
        }
684

685
        // Iterate over all boxes again, this time creating the appropriate input control and insert it
686
        // at the position indicated by the placeholder.
687
        for ($i = 0; $i <= $part->numbox; $i++) {
2,079✔
688
            // For normal answer fields, the placeholder is {_N} with N being the number of the
689
            // answer, starting from 0. The unit field, if there is one, comes last and has the
690
            // {_u} placeholder.
691
            if ($i < $part->numbox) {
2,079✔
692
                $answerindex = $i;
2,079✔
693
                $placeholder = "_$i";
2,079✔
694
            } else if (!empty($part->postunit)) {
2,079✔
695
                $answerindex = self::UNIT_FIELD;
777✔
696
                $placeholder = '_u';
777✔
697
            }
698

699
            // If the user has requested a multi-choice element, they must have specified an array
700
            // variable containing the options. We try to fetch that variable. If this fails, we
701
            // simply continue and build a text field instead.
702
            $optiontexts = null;
2,079✔
703
            if (!empty($boxes[$placeholder]['options'])) {
2,079✔
704
                try {
705
                    $optiontexts = $part->evaluator->export_single_variable($boxes[$placeholder]['options']);
147✔
706
                } catch (Exception $e) {
21✔
707
                    // TODO: use non-capturing catch.
708
                    unset($e);
21✔
709
                }
710
            }
711

712
            if ($optiontexts === null) {
2,079✔
713
                $inputfieldhtml = $this->create_input_box(
1,995✔
714
                    $part, $answerindex, $qa, $options, $boxes[$placeholder]['format'], $sub->feedbackclass
1,995✔
715
                );
1,995✔
716
            } else if ($boxes[$placeholder]['dropdown']) {
126✔
717
                $inputfieldhtml = $this->create_dropdown_mc_answer(
63✔
718
                    $part, $i, $qa, $optiontexts->value, $boxes[$placeholder]['shuffle'], $options
63✔
719
                );
63✔
720
            } else {
721
                $inputfieldhtml = $this->create_radio_mc_answer(
63✔
722
                    $part, $i, $qa, $optiontexts->value, $boxes[$placeholder]['shuffle'], $options, $sub->feedbackclass
63✔
723
                );
63✔
724
            }
725

726
            // The replacement text *might* contain a backslash and in the worst case this might
727
            // lead to an erroneous backreference, e. g. if the student's answer was \1. Thus,
728
            // we better use preg_replace_callback() instead of just preg_replace(), as this allows
729
            // us to ignore such unintentional backreferences.
730
            $subqreplaced = preg_replace_callback(
2,079✔
731
                '/' . preg_quote($boxes[$placeholder]['placeholder'], '/') . '/',
2,079✔
732
                function ($matches) use ($inputfieldhtml) {
2,079✔
733
                    return $inputfieldhtml;
2,079✔
734
                },
2,079✔
735
                $subqreplaced,
2,079✔
736
                1
2,079✔
737
            );
2,079✔
738
        }
739

740
        return $subqreplaced;
2,079✔
741
    }
742

743
    /**
744
     * Correct response for the question. This is not needed for the Formulas question, because
745
     * answers are relative to parts.
746
     *
747
     * @param question_attempt $qa question attempt that will be displayed on the page
748
     * @return string empty string
749
     */
750
    public function correct_response(question_attempt $qa) {
751
        return '';
378✔
752
    }
753

754
    /**
755
     * Generate an automatic description of the correct response for a given part.
756
     *
757
     * @param qtype_formulas_part $part question part
758
     * @return string HTML fragment
759
     */
760
    public function part_correct_response($part) {
761
        $answers = $part->get_correct_response(true);
378✔
762
        $answertext = implode('; ', $answers);
378✔
763

764
        $string = ($part->answernotunique ? 'correctansweris' : 'uniquecorrectansweris');
378✔
765
        return html_writer::nonempty_tag(
378✔
766
            'div', get_string($string, 'qtype_formulas', $answertext), ['class' => 'formulaspartcorrectanswer']
378✔
767
        );
378✔
768
    }
769

770
    /**
771
     * Generate a brief statement of how many sub-parts of this question the
772
     * student got right.
773
     *
774
     * @param question_attempt $qa question attempt that will be displayed on the page
775
     * @return string HTML fragment
776
     */
777
    protected function num_parts_correct(question_attempt $qa) {
778
        /** @var qtype_formulas_question $question */
779
        $question = $qa->get_question();
252✔
780
        $response = $qa->get_last_qt_data();
252✔
781
        if (!$question->is_gradable_response($response)) {
252✔
782
            return '';
105✔
783
        }
784

785
        $numright = $question->get_num_parts_right($response)[0];
252✔
786
        if ($numright === 1) {
252✔
787
            return get_string('yougotoneright', 'qtype_formulas');
42✔
788
        } else {
789
            return get_string('yougotnright', 'qtype_formulas', $numright);
231✔
790
        }
791
    }
792

793
    /**
794
     * We need to owerwrite this method to replace global variables by their value.
795
     *
796
     * @param question_attempt $qa question attempt that will be displayed on the page
797
     * @param question_hint $hint the hint to be shown
798
     * @return string HTML fragment
799
     */
800
    protected function hint(question_attempt $qa, question_hint $hint) {
801
        /** @var qtype_formulas_question $question */
802
        $question = $qa->get_question();
42✔
803
        $hint->hint = $question->evaluator->substitute_variables_in_text($hint->hint);
42✔
804

805
        return html_writer::nonempty_tag('div', $question->format_hint($hint, $qa), ['class' => 'hint']);
42✔
806
    }
807

808
    /**
809
     * Generate HTML fragment for the question's combined feedback.
810
     *
811
     * @param question_attempt $qa question attempt that will be displayed on the page
812
     * @return string HTML fragment
813
     */
814
    protected function combined_feedback(question_attempt $qa) {
815
        /** @var qtype_formulas_question $question */
816
        $question = $qa->get_question();
483✔
817

818
        $state = $qa->get_state();
483✔
819
        if (!$state->is_finished()) {
483✔
820
            $response = $qa->get_last_qt_data();
147✔
821
            if (!$question->is_gradable_response($response)) {
147✔
822
                return '';
105✔
823
            }
824
            $state = $question->grade_response($response)[1];
147✔
825
        }
826

827
        // The feedback will be in ->correctfeedback, ->partiallycorrectfeedback or ->incorrectfeedback,
828
        // with the corresponding ->...feedbackformat setting. We create the property names here to simplify
829
        // access.
830
        $fieldname = $state->get_feedback_class() . 'feedback';
483✔
831
        $formatname = $state->get_feedback_class() . 'feedbackformat';
483✔
832

833
        // If there is no feedback, we return an empty string.
834
        if (strlen(trim($question->$fieldname)) === 0) {
483✔
835
            return '';
×
836
        }
837

838
        // Otherwise, we return the appropriate feedback. The text is run through format_text() to have
839
        // variables replaced.
840
        return $question->format_text(
483✔
841
            $question->$fieldname, $question->$formatname, $qa, 'question', $fieldname, $question->id, false
483✔
842
        );
483✔
843
    }
844

845
    /**
846
     * Generate the specific feedback. This is feedback that varies according to
847
     * the response the student gave.
848
     *
849
     * @param question_attempt $qa question attempt that will be displayed on the page
850
     * @return string
851
     */
852
    public function specific_feedback(question_attempt $qa) {
853
        return $this->combined_feedback($qa);
483✔
854
    }
855

856
    /**
857
     * Gereate the part's general feedback. This is feedback is shown to all students.
858
     *
859
     * @param question_attempt $qa question attempt that will be displayed on the page
860
     * @param question_display_options $options controls what should and should not be displayed
861
     * @param qtype_formulas_part $part question part
862
     * @return string HTML fragment
863
     */
864
    protected function part_general_feedback(question_attempt $qa, question_display_options $options, qtype_formulas_part $part) {
865
        /** @var qtype_formulas_question $question */
866
        $question = $qa->get_question();
2,520✔
867
        $state = $qa->get_state();
2,520✔
868

869
        // If no feedback should be shown, we return an empty string.
870
        if (!$options->feedback) {
2,520✔
871
            return '';
2,478✔
872
        }
873

874
        // If we use the adaptive multipart behaviour, there will be some feedback about the grading,
875
        // e. g. the obtained marks for this submission and the attracted penalty.
876
        $gradingdetailsdiv = '';
483✔
877
        if ($qa->get_behaviour_name() == 'adaptivemultipart') {
483✔
878
            // This is rather a hack, but it will probably work.
879
            $renderer = $this->page->get_renderer('qbehaviour_adaptivemultipart');
105✔
880
            $details = $qa->get_behaviour()->get_part_mark_details($part->partindex);
105✔
881
            $gradingdetailsdiv = $renderer->render_adaptive_marks($details, $options);
105✔
882
            $state = $details->state;
105✔
883
        }
884
        // If the question is in a state that does not yet allow to give a feedback,
885
        // we return an empty string.
886
        if (empty($state->get_feedback_class())) {
483✔
887
            return '';
42✔
888
        }
889

890
        // If we have a general feedback, we substitute local / grading variables and
891
        // wrap it in a <div>.
892
        $feedbackdiv = '';
462✔
893
        if (strlen(trim($part->feedback)) !== 0) {
462✔
894
            $feedbacktext = $part->evaluator->substitute_variables_in_text($part->feedback);
273✔
895
            $feedbacktext = $question->format_text(
273✔
896
                $feedbacktext,
273✔
897
                FORMAT_HTML,
273✔
898
                $qa,
273✔
899
                'qtype_formulas',
273✔
900
                'answerfeedback',
273✔
901
                $part->id,
273✔
902
                false
273✔
903
            );
273✔
904
            $feedbackdiv = html_writer::tag('div', $feedbacktext , ['class' => 'feedback formulaslocalfeedback']);
273✔
905
        }
906

907
        // Append the grading details, if they exist. If the result is not empty, wrap in
908
        // a <div> and return.
909
        $feedbackdiv .= $gradingdetailsdiv;
462✔
910
        if (!empty($feedbackdiv)) {
462✔
911
            return html_writer::nonempty_tag(
294✔
912
                'div', $feedbackdiv, ['class' => 'formulaspartfeedback formulaspartfeedback-' . $part->partindex]
294✔
913
            );
294✔
914
        }
915

916
        // Still here? Then we return an empty string.
917
        return '';
189✔
918
    }
919

920
    /**
921
     * Generate HTML fragment for the part's combined feedback.
922
     *
923
     * @param question_attempt $qa question attempt that will be displayed on the page
924
     * @param question_display_options $options controls what should and should not be displayed
925
     * @param qtype_formulas_part $part question part
926
     * @param float $fraction the obtained grade
927
     * @return string HTML fragment
928
     */
929
    protected function part_combined_feedback(question_attempt $qa, question_display_options $options,
930
        qtype_formulas_part $part, float $fraction): string {
931
        $feedback = '';
2,520✔
932
        $showfeedback = false;
2,520✔
933
        /** @var qtype_formulas_question $question */
934
        $question = $qa->get_question();
2,520✔
935
        $state = $qa->get_state();
2,520✔
936
        $feedbackclass = $state->get_feedback_class();
2,520✔
937

938
        if ($qa->get_behaviour_name() == 'adaptivemultipart') {
2,520✔
939
            $details = $qa->get_behaviour()->get_part_mark_details($part->partindex);
105✔
940
            $feedbackclass = $details->state->get_feedback_class();
105✔
941
        } else {
942
            $state = question_state::graded_state_for_fraction($fraction);
2,436✔
943
            $feedbackclass = $state->get_feedback_class();
2,436✔
944
        }
945
        if ($feedbackclass != '') {
2,520✔
946
            $showfeedback = $options->feedback;
2,520✔
947
            $field = 'part' . $feedbackclass . 'fb';
2,520✔
948
            $format = 'part' . $feedbackclass . 'fbformat';
2,520✔
949
            if ($part->$field) {
2,520✔
950
                // Clone the part's evaluator and substitute local / grading vars first.
951
                $part->$field = $part->evaluator->substitute_variables_in_text($part->$field);
2,499✔
952
                $feedback = $question->format_text($part->$field, $part->$format,
2,499✔
953
                        $qa, 'qtype_formulas', $field, $part->id, false);
2,499✔
954
            }
955
        }
956
        if ($showfeedback && $feedback) {
2,520✔
957
                $feedback = html_writer::tag('div', $feedback , ['class' => 'feedback formulaslocalfeedback']);
462✔
958
                return html_writer::nonempty_tag('div', $feedback,
462✔
959
                        ['class' => 'formulaspartfeedback formulaspartfeedback-' . $part->partindex]);
462✔
960
        }
961
        return '';
2,478✔
962
    }
963
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc