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

FormulasQuestion / moodle-qtype_formulas / 15519751976

08 Jun 2025 03:04PM UTC coverage: 97.435% (+0.2%) from 97.261%
15519751976

push

github

web-flow
rewrite renderer.php (#214)

221 of 223 new or added lines in 1 file covered. (99.1%)

4026 of 4132 relevant lines covered (97.43%)

1581.29 hits per line

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

93.11
/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();
588✔
53

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

59
        $questiontext = '';
588✔
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) {
588✔
63
            $questiontext .= $question->format_text(
588✔
64
                $question->textfragments[$part->partindex], $question->questiontextformat,
588✔
65
                $qa, 'question', 'questiontext', $question->id, false
588✔
66
            );
588✔
67
            $questiontext .= $this->part_formulation_and_controls($qa, $options, $part);
588✔
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(
588✔
72
            end($question->textfragments), $question->questiontextformat, $qa, 'question', 'questiontext', $question->id, false
588✔
73
        );
588✔
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']);
588✔
78
        if ($qa->get_state() == question_state::$invalid) {
588✔
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;
588✔
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;
98
        $this->page->requires->js_call_amd('qtype_formulas/answervalidation', 'init');
×
99

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

106
        return '';
×
107
    }
108

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

120
        // The behaviour might change the display options per part, so it is safer to clone them here.
121
        $partoptions = clone $options;
588✔
122
        if ($qa->get_behaviour_name() === 'adaptivemultipart') {
588✔
123
            $qa->get_behaviour()->adjust_display_options_for_part($part->partindex, $partoptions);
105✔
124
        }
125

126
        // Fetch information about the outcome: grade, feedback symbol, CSS class to be used.
127
        $outcomedata = $this->get_part_feedback_class_and_symbol($qa, $partoptions, $part);
588✔
128

129
        // First of all, we take the part's question text and its input fields.
130
        $output = $this->get_part_formulation($qa, $partoptions, $part, $outcomedata);
588✔
131

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

140
        // The part's feedback consists of the combined feedback (correct, partially correct, incorrect -- depending on the
141
        // outcome) and the general feedback which is given in all cases.
142
        $feedback = $this->part_combined_feedback($qa, $partoptions, $part, $outcomedata->fraction);
588✔
143
        $feedback .= $this->part_general_feedback($qa, $partoptions, $part);
588✔
144

145
        // If requested, the correct answer should be appended to the feedback.
146
        if ($partoptions->rightanswer) {
588✔
147
            $feedback .= $this->part_correct_response($part);
378✔
148
        }
149

150
        // Put all feedback into a <div> with the appropriate CSS class and append it to the output.
151
        $output .= html_writer::nonempty_tag('div', $feedback, ['class' => 'formulaspartoutcome outcome']);
588✔
152

153
        return html_writer::tag('div', $output , ['class' => 'formulaspart']);
588✔
154
    }
155

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

169
        // Fetch the last response data and grade it.
170
        $response = $qa->get_last_qt_data();
588✔
171
        list('answer' => $answergrade, 'unit' => $unitcorrect) = $part->grade($response);
588✔
172

173
        // The fraction will later be used to determine which feedback (correct, partially correct or incorrect)
174
        // to use. We have to take into account a possible deduction for a wrong unit.
175
        $result->fraction = $answergrade;
588✔
176
        if ($unitcorrect === false) {
588✔
177
            $result->fraction *= (1 - $part->unitpenalty);
399✔
178
        }
179

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

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

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

240
        $variablename = "{$part->partindex}_{$answerindex}";
42✔
241
        $currentanswer = $qa->get_last_qt_var($variablename);
42✔
242
        $inputname = $qa->get_qt_field_name($variablename);
42✔
243

244
        $inputattributes['type'] = 'radio';
42✔
245
        $inputattributes['name'] = $inputname;
42✔
246
        if ($displayoptions->readonly) {
42✔
247
            $inputattributes['disabled'] = 'disabled';
21✔
248
        }
249

250
        // First, we open a <fieldset> around the entire group of options.
251
        $output = html_writer::start_tag('fieldset', ['class' => 'multichoice_answer']);
42✔
252

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

262
        // Iterate over all options.
263
        foreach ($answeroptions as $i => $optiontext) {
42✔
264
            $numbering = html_writer::span(self::number_in_style($i, $question->answernumbering), 'answernumber');
42✔
265
            $labeltext = $question->format_text(
42✔
266
                $numbering . $optiontext, $part->subqtextformat , $qa, 'qtype_formulas', 'answersubqtext', $part->id, false
42✔
267
            );
42✔
268

269
            $inputattributes['id'] = $inputname . '_' . $i;
42✔
270
            $inputattributes['value'] = $i;
42✔
271
            // Class ml-3 is Bootstrap's class for margin-left: 1rem; it used to be m-l-1.
272
            $label = $this->create_label_for_input($labeltext, $inputattributes['id'], ['class' => 'ml-3']);
42✔
273
            $inputattributes['aria-labelledby'] = $label['id'];
42✔
274

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

280
            // We do not reset the $inputattributes array on each iteration, so we have to add/remove the
281
            // attribute every time.
282
            if ($isselected) {
42✔
283
                $inputattributes['checked'] = 'checked';
21✔
284
            } else {
285
                unset($inputattributes['checked']);
42✔
286
            }
287

288
            // Each option (radio box element plus label) is wrapped in its own <div> element.
289
            $divclass = 'r' . ($i % 2);
42✔
290
            if ($displayoptions->correctness && $isselected) {
42✔
291
                $divclass .= ' ' . $feedbackclass;
21✔
292
            }
293
            $output .= html_writer::start_div($divclass);
42✔
294

295
            // Now add the <input> tag and its <label>.
296
            $output .= html_writer::empty_tag('input', $inputattributes);
42✔
297
            $output .= $label['html'];
42✔
298

299
            // Close the option's <div>.
300
            $output .= html_writer::end_div();
42✔
301
        }
302

303
        // Close the option group's <fieldset>.
304
        $output .= html_writer::end_tag('fieldset');
42✔
305

306
        return $output;
42✔
307
    }
308

309
    /**
310
     * Create a <label> element for a given input control (e. g. a text field). Returns the
311
     * HTML and the label's ID.
312
     *
313
     * @param string $text the label's text
314
     * @param string $inputid ID of the input control for which the label is created
315
     * @param array $additionalattributes possibility to add custom attributes, attribute name => value
316
     * @return array 'id' => label's ID to be used in 'aria-labelledby' attribute, 'html' => HTML code
317
     */
318
    protected function create_label_for_input(string $text, string $inputid, array $additionalattributes = []): array {
319
        $labelid = 'lbl_' . str_replace(':', '__', $inputid);
588✔
320
        $attributes = [
588✔
321
            'class' => 'subq accesshide',
588✔
322
            'for' => $inputid,
588✔
323
            'id' => $labelid,
588✔
324
        ];
588✔
325

326
        // Merging the additional attributes with the default attributes; left array has precedence when
327
        // using the + operator.
328
        $attributes = $additionalattributes + $attributes;
588✔
329

330
        return [
588✔
331
            'id' => $labelid,
588✔
332
            'html' => html_writer::tag('label', $text, $attributes),
588✔
333
        ];
588✔
334
    }
335

336
    /**
337
     * Create a <select> field for a multiple choice answer input.
338
     *
339
     * @param qtype_formulas_part $part question part
340
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
341
     * @param question_attempt $qa question attempt that will be displayed on the page
342
     * @param array $answeroptions array of strings containing the answer options to choose from
343
     * @param question_display_options $displayoptions controls what should and should not be displayed
344
     * @return string HTML fragment
345
     */
346
    protected function create_dropdown_mc_answer(qtype_formulas_part $part, $answerindex, question_attempt $qa,
347
            array $answeroptions, question_display_options $displayoptions): string {
348
        /** @var qype_formulas_question $question */
349
        $question = $qa->get_question();
42✔
350

351
        $variablename = "{$part->partindex}_{$answerindex}";
42✔
352
        $currentanswer = $qa->get_last_qt_var($variablename);
42✔
353
        $inputname = $qa->get_qt_field_name($variablename);
42✔
354

355
        $inputattributes['name'] = $inputname;
42✔
356
        $inputattributes['value'] = $currentanswer;
42✔
357
        $inputattributes['id'] = $inputname;
42✔
358

359
        $label = $this->create_label_for_input(
42✔
360
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
42✔
361
            $inputname
42✔
362
        );
42✔
363
        $inputattributes['aria-labelledby'] = $label['id'];
42✔
364

365
        if ($displayoptions->readonly) {
42✔
366
            $inputattributes['disabled'] = 'disabled';
21✔
367
        }
368

369
        // First, we open a <span> around the dropdown field and its accessibility label.
370
        $output = html_writer::start_tag('span', ['class' => 'formulas_menu']);
42✔
371
        $output .= $label['html'];
42✔
372

373
        // Iterate over all options.
374
        $entries = [];
42✔
375
        foreach ($answeroptions as $optiontext) {
42✔
376
            $entries[] = $question->format_text(
42✔
377
                $optiontext, $part->subqtextformat , $qa, 'qtype_formulas', 'answersubqtext', $part->id, false
42✔
378
            );
42✔
379
        }
380
        $output .= html_writer::select($entries, $inputname, $currentanswer, ['' => ''], $inputattributes);
42✔
381
        $output .= html_writer::end_tag('span');
42✔
382

383
        return $output;
42✔
384
    }
385

386
    /**
387
     * Generate the right label for the input control, depending on the number of answers in the given part
388
     * and the number of parts in the question. Special cases (combined field, unit field) are also taken
389
     * into account. Returns the appropriate string from the language file. Examples are "Answer field for
390
     * part X", "Answer field X for part Y" or "Answer and unit for part X".
391
     *
392
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
393
     * @param int $totalanswers number of answers for the given part
394
     * @param int $partindex number of the part (starting at 0) in this question
395
     * @param int $totalparts number of parts in the question
396
     * @return string localized string
397
     */
398
    protected function generate_accessibility_label_text($answerindex, int $totalanswers, int $partindex,
399
            int $totalparts): string {
400

401
        // Some language strings need parameters.
402
        $labeldata = new stdClass();
588✔
403

404
        // The language strings start with 'answerunit' for a separate unit field, 'answercombinedunit' for
405
        // a combined field, 'answercoordinate' for an answer field when there are multiple answers in the
406
        // part or just 'answer' if there is a single field.
407
        $labelstring = 'answer';
588✔
408
        if ($answerindex === self::UNIT_FIELD) {
588✔
409
            $labelstring .= 'unit';
126✔
410
        } else if ($answerindex === self::COMBINED_FIELD) {
588✔
411
            $labelstring .= 'combinedunit';
357✔
412
        } else if ($totalanswers > 1) {
315✔
413
            $labelstring .= 'coordinate';
63✔
414
            $labeldata->numanswer = $answerindex + 1;
63✔
415
        }
416

417
        // The language strings end with 'multi' for multi-part questions or 'single' for single-part
418
        // questions.
419
        if ($totalparts > 1) {
588✔
420
            $labelstring .= 'multi';
105✔
421
            $labeldata->part = $partindex + 1;
105✔
422
        } else {
423
            $labelstring .= 'single';
525✔
424
        }
425

426
        return get_string($labelstring, 'qtype_formulas', $labeldata);
588✔
427
    }
428

429
    /**
430
     * Create an <input> field.
431
     *
432
     * @param qtype_formulas_part $part question part
433
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
434
     * @param question_attempt $qa question attempt that will be displayed on the page
435
     * @param question_display_options $displayoptions controls what should and should not be displayed
436
     * @param string $feedbackclass
437
     * @return string HTML fragment
438
     */
439
    protected function create_input_box(qtype_formulas_part $part, $answerindex,
440
            question_attempt $qa, question_display_options $displayoptions, string $feedbackclass = ''): string {
441
        /** @var qype_formulas_question $question */
442
        $question = $qa->get_question();
546✔
443

444
        // The variable name will be N_ for the (single) combined unit field of part N,
445
        // or N_M for answer #M in part #N. If #M is equal to the part's numbox (i. e. the
446
        // number of answers), it is a unit field; note that the fields are numbered starting
447
        // from 0, so with 3 answers, we have N_0, N_1, N_2 and only use N_3 if there is a
448
        // unit.
449
        $variablename = $part->partindex . '_';
546✔
450
        if ($answerindex === self::UNIT_FIELD) {
546✔
451
            $variablename .= $part->numbox;
126✔
452
        } else {
453
            $variablename .= ($answerindex === self::COMBINED_FIELD ? '' : $answerindex);
546✔
454
        }
455

456
        $currentanswer = $qa->get_last_qt_var($variablename);
546✔
457
        $inputname = $qa->get_qt_field_name($variablename);
546✔
458

459
        // Text fields will have a tooltip attached. The tooltip's content depends on the
460
        // answer type. Special tooltips exist for combined or separate unit fields.
461
        switch ($part->answertype) {
546✔
462
            case qtype_formulas::ANSWER_TYPE_NUMERIC:
463
                $titlestring = 'numeric';
21✔
464
                break;
21✔
465
            case qtype_formulas::ANSWER_TYPE_NUMERICAL_FORMULA:
466
                $titlestring = 'numerical_formula';
21✔
467
                break;
21✔
468
            case qtype_formulas::ANSWER_TYPE_ALGEBRAIC:
469
                $titlestring = 'algebraic_formula';
42✔
470
                break;
42✔
471
            case qtype_formulas::ANSWER_TYPE_NUMBER:
472
            default:
473
                $titlestring = 'number';
525✔
474
        }
475
        if ($answerindex === self::COMBINED_FIELD) {
546✔
476
            $titlestring .= '_unit';
357✔
477
        }
478
        if ($answerindex === self::UNIT_FIELD) {
546✔
479
            $titlestring = 'unit';
126✔
480
        }
481
        $title = get_string($titlestring, 'qtype_formulas');
546✔
482

483
        $inputattributes = [
546✔
484
            'type' => 'text',
546✔
485
            'name' => $inputname,
546✔
486
            'value' => $currentanswer,
546✔
487
            'id' => $inputname,
546✔
488

489
            'data-answertype' => ($answerindex === self::UNIT_FIELD ? 'unit' : $part->answertype),
546✔
490
            'data-withunit' => ($answerindex === self::COMBINED_FIELD ? '1' : '0'),
546✔
491

492
            'data-toggle' => 'tooltip',
546✔
493
            'data-title' => $title,
546✔
494
            'data-custom-class' => 'qtype_formulas-tooltip',
546✔
495
            'title' => $title,
546✔
496
            'class' => "form-control formulas_{$titlestring} {$feedbackclass}",
546✔
497
            'maxlength' => 128,
546✔
498
        ];
546✔
499

500
        if ($displayoptions->readonly) {
546✔
501
            $inputattributes['readonly'] = 'readonly';
357✔
502
        }
503

504
        $label = $this->create_label_for_input(
546✔
505
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
546✔
506
            $inputname
546✔
507
        );
546✔
508
        $inputattributes['aria-labelledby'] = $label['id'];
546✔
509

510
        $output = $label['html'];
546✔
511
        $output .= html_writer::empty_tag('input', $inputattributes);
546✔
512

513
        return $output;
546✔
514
    }
515

516
    /**
517
     * Return the part's text with variables replaced by their values.
518
     *
519
     * @param question_attempt $qa question attempt that will be displayed on the page
520
     * @param question_display_options $options controls what should and should not be displayed
521
     * @param qtype_formulas_part $part question part
522
     * @param stdClass $sub class and symbol for the part feedback
523
     * @return string HTML fragment
524
     */
525
    public function get_part_formulation(question_attempt $qa, question_display_options $options,
526
            qtype_formulas_part $part, stdClass $sub): string {
527
        /** @var qype_formulas_question $question */
528
        $question = $qa->get_question();
588✔
529

530
        // Clone the part's evaluator and remove special variables like _0 etc., because they must
531
        // not be substituted here; otherwise, we would lose input boxes.
532
        $evaluator = clone $part->evaluator;
588✔
533
        $evaluator->remove_special_vars();
588✔
534
        $text = $evaluator->substitute_variables_in_text($part->subqtext);
588✔
535

536
        $subqreplaced = $question->format_text(
588✔
537
            $text, $part->subqtextformat, $qa, 'qtype_formulas', 'answersubqtext', $part->id, false
588✔
538
        );
588✔
539

540
        // Get the set of defined placeholders and their options.
541
        $boxes = $part->scan_for_answer_boxes($subqreplaced);
588✔
542

543
        // Append missing placholders at the end of part. We do not put a space before the opening
544
        // or after the closing brace, in order to get {_0}{_u} for questions with one answer and
545
        // a unit. This makes sure that the question will receive a combined unit field.
546
        for ($i = 0; $i <= $part->numbox; $i++) {
588✔
547
            // If no unit has been set, we do not append the {_u} placeholder.
548
            if ($i == $part->numbox && empty($part->postunit)) {
588✔
549
                continue;
294✔
550
            }
551
            $placeholder = ($i == $part->numbox) ? '_u' : "_{$i}";
588✔
552
            // If the placeholder does not exist yet, we create it with default settings, i. e. no multi-choice.
553
            if (!array_key_exists($placeholder, $boxes)) {
588✔
554
                $boxes[$placeholder] = ['placeholder' => '{' . $placeholder . '}', 'options' => '', 'dropdown' => false];
168✔
555
                $subqreplaced .= '{' . $placeholder . '}';
168✔
556
            }
557
        }
558

559
        // If part has combined unit answer input.
560
        if ($part->has_combined_unit_field()) {
588✔
561
            $combinedfieldhtml = $this->create_input_box($part, self::COMBINED_FIELD, $qa, $options, $sub->feedbackclass);
357✔
562
            return str_replace('{_0}{_u}', $combinedfieldhtml, $subqreplaced);
357✔
563
        }
564

565
        // Iterate over all boxes again, this time creating the appropriate input control and insert it
566
        // at the position indicated by the placeholder.
567
        for ($i = 0; $i <= $part->numbox; $i++) {
315✔
568
            // For normal answer fields, the placeholder is {_N} with N being the number of the
569
            // answer, starting from 0. The unit field, if there is one, comes last and has the
570
            // {_u} placeholder.
571
            if ($i < $part->numbox) {
315✔
572
                $answerindex = $i;
315✔
573
                $placeholder = "_$i";
315✔
574
            } else if (!empty($part->postunit)) {
315✔
575
                $answerindex = self::UNIT_FIELD;
126✔
576
                $placeholder = '_u';
126✔
577
            }
578

579
            // If the user has requested a multi-choice element, they must have specified an array
580
            // variable containing the options. We try to fetch that variable. If this fails, we
581
            // simply continue and build a text field instead.
582
            $optiontexts = null;
315✔
583
            if (!empty($boxes[$placeholder]['options'])) {
315✔
584
                try {
585
                    $optiontexts = $part->evaluator->export_single_variable($boxes[$placeholder]['options']);
105✔
586
                } catch (Exception $e) {
21✔
587
                    // TODO: use non-capturing catch.
588
                    unset($e);
21✔
589
                }
590
            }
591

592
            if ($optiontexts === null) {
315✔
593
                $inputfieldhtml = $this->create_input_box($part, $answerindex, $qa, $options, $sub->feedbackclass);
273✔
594
            } else if ($boxes[$placeholder]['dropdown']) {
84✔
595
                $inputfieldhtml = $this->create_dropdown_mc_answer($part, $i, $qa, $optiontexts->value, $options);
42✔
596
            } else {
597
                $inputfieldhtml = $this->create_radio_mc_answer(
42✔
598
                    $part, $i, $qa, $optiontexts->value, $options, $sub->feedbackclass
42✔
599
                );
42✔
600
            }
601

602
            // The replacement text *might* contain a backslash and in the worst case this might
603
            // lead to an erroneous backreference, e. g. if the student's answer was \1. Thus,
604
            // we better use preg_replace_callback() instead of just preg_replace(), as this allows
605
            // us to ignore such unintentional backreferences.
606
            $subqreplaced = preg_replace_callback(
315✔
607
                '/' . preg_quote($boxes[$placeholder]['placeholder'], '/') . '/',
315✔
608
                function ($matches) use ($inputfieldhtml) {
315✔
609
                    return $inputfieldhtml;
315✔
610
                },
315✔
611
                $subqreplaced,
315✔
612
                1
315✔
613
            );
315✔
614
        }
615

616
        return $subqreplaced;
315✔
617
    }
618

619
    /**
620
     * Correct response for the question. This is not needed for the Formulas question, because
621
     * answers are relative to parts.
622
     *
623
     * @param question_attempt $qa question attempt that will be displayed on the page
624
     * @return string empty string
625
     */
626
    public function correct_response(question_attempt $qa) {
627
        return '';
378✔
628
    }
629

630
    /**
631
     * Generate an automatic description of the correct response for a given part.
632
     *
633
     * @param qtype_formulas_part $part question part
634
     * @return string HTML fragment
635
     */
636
    public function part_correct_response($part) {
637
        $answers = $part->get_correct_response(true);
378✔
638
        $answertext = implode('; ', $answers);
378✔
639

640
        $string = ($part->answernotunique ? 'correctansweris' : 'uniquecorrectansweris');
378✔
641
        return html_writer::nonempty_tag(
378✔
642
            'div', get_string($string, 'qtype_formulas', $answertext), ['class' => 'formulaspartcorrectanswer']
378✔
643
        );
378✔
644
    }
645

646
    /**
647
     * Generate a brief statement of how many sub-parts of this question the
648
     * student got right.
649
     *
650
     * @param question_attempt $qa question attempt that will be displayed on the page
651
     * @return string HTML fragment
652
     */
653
    protected function num_parts_correct(question_attempt $qa) {
654
        /** @var qtype_formulas_question $question */
655
        $question = $qa->get_question();
252✔
656
        $response = $qa->get_last_qt_data();
252✔
657
        if (!$question->is_gradable_response($response)) {
252✔
658
            return '';
105✔
659
        }
660

661
        $numright = $question->get_num_parts_right($response)[0];
252✔
662
        if ($numright === 1) {
252✔
663
            return get_string('yougotoneright', 'qtype_formulas');
42✔
664
        } else {
665
            return get_string('yougotnright', 'qtype_formulas', $numright);
231✔
666
        }
667
    }
668

669
    /**
670
     * We need to owerwrite this method to replace global variables by their value.
671
     *
672
     * @param question_attempt $qa question attempt that will be displayed on the page
673
     * @param question_hint $hint the hint to be shown
674
     * @return string HTML fragment
675
     */
676
    protected function hint(question_attempt $qa, question_hint $hint) {
677
        /** @var qtype_formulas_question $question */
678
        $question = $qa->get_question();
42✔
679
        $hint->hint = $question->evaluator->substitute_variables_in_text($hint->hint);
42✔
680

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

684
    /**
685
     * Generate HTML fragment for the question's combined feedback.
686
     *
687
     * @param question_attempt $qa question attempt that will be displayed on the page
688
     * @return string HTML fragment
689
     */
690
    protected function combined_feedback(question_attempt $qa) {
691
        /** @var qtype_formulas_question $question */
692
        $question = $qa->get_question();
483✔
693

694
        $state = $qa->get_state();
483✔
695
        if (!$state->is_finished()) {
483✔
696
            $response = $qa->get_last_qt_data();
147✔
697
            if (!$question->is_gradable_response($response)) {
147✔
698
                return '';
105✔
699
            }
700
            $state = $question->grade_response($response)[1];
147✔
701
        }
702

703
        // The feedback will be in ->correctfeedback, ->partiallycorrectfeedback or ->incorrectfeedback,
704
        // with the corresponding ->...feedbackformat setting. We create the property names here to simplify
705
        // access.
706
        $fieldname = $state->get_feedback_class() . 'feedback';
483✔
707
        $formatname = $state->get_feedback_class() . 'feedbackformat';
483✔
708

709
        // If there is no feedback, we return an empty string.
710
        if (strlen(trim($question->$fieldname)) === 0) {
483✔
NEW
711
            return '';
×
712
        }
713

714
        // Otherwise, we return the appropriate feedback. The text is run through format_text() to have
715
        // variables replaced.
716
        return $question->format_text(
483✔
717
            $question->$fieldname, $question->$formatname, $qa, 'question', $fieldname, $question->id, false
483✔
718
        );
483✔
719
    }
720

721
    /**
722
     * Generate the specific feedback. This is feedback that varies according to
723
     * the response the student gave.
724
     *
725
     * @param question_attempt $qa question attempt that will be displayed on the page
726
     * @return string
727
     */
728
    public function specific_feedback(question_attempt $qa) {
729
        return $this->combined_feedback($qa);
483✔
730
    }
731

732
    /**
733
     * Gereate the part's general feedback. This is feedback is shown to all students.
734
     *
735
     * @param question_attempt $qa question attempt that will be displayed on the page
736
     * @param question_display_options $options controls what should and should not be displayed
737
     * @param qtype_formulas_part $part question part
738
     * @return string HTML fragment
739
     */
740
    protected function part_general_feedback(question_attempt $qa, question_display_options $options, qtype_formulas_part $part) {
741
        /** @var qtype_formulas_question $question */
742
        $question = $qa->get_question();
588✔
743
        $state = $qa->get_state();
588✔
744

745
        // If no feedback should be shown, we return an empty string.
746
        if (!$options->feedback) {
588✔
747
            return '';
546✔
748
        }
749

750
        // If we use the adaptive multipart behaviour, there will be some feedback about the grading,
751
        // e. g. the obtained marks for this submission and the attracted penalty.
752
        $gradingdetailsdiv = '';
483✔
753
        if ($qa->get_behaviour_name() == 'adaptivemultipart') {
483✔
754
            // This is rather a hack, but it will probably work.
755
            $renderer = $this->page->get_renderer('qbehaviour_adaptivemultipart');
105✔
756
            $details = $qa->get_behaviour()->get_part_mark_details($part->partindex);
105✔
757
            $gradingdetailsdiv = $renderer->render_adaptive_marks($details, $options);
105✔
758
            $state = $details->state;
105✔
759
        }
760
        // If the question is in a state that does not yet allow to give a feedback,
761
        // we return an empty string.
762
        if (empty($state->get_feedback_class())) {
483✔
763
            return '';
42✔
764
        }
765

766
        // If we have a general feedback, we substitute local / grading variables and
767
        // wrap it in a <div>.
768
        $feedbackdiv = '';
462✔
769
        if (strlen(trim($part->feedback)) !== 0) {
462✔
770
            $feedbacktext = $part->evaluator->substitute_variables_in_text($part->feedback);
273✔
771
            $feedbacktext = $question->format_text(
273✔
772
                $feedbacktext,
273✔
773
                FORMAT_HTML,
273✔
774
                $qa,
273✔
775
                'qtype_formulas',
273✔
776
                'answerfeedback',
273✔
777
                $part->id,
273✔
778
                false
273✔
779
            );
273✔
780
            $feedbackdiv = html_writer::tag('div', $feedbacktext , ['class' => 'feedback formulaslocalfeedback']);
273✔
781
        }
782

783
        // Append the grading details, if they exist. If the result is not empty, wrap in
784
        // a <div> and return.
785
        $feedbackdiv .= $gradingdetailsdiv;
462✔
786
        if (!empty($feedbackdiv)) {
462✔
787
            return html_writer::nonempty_tag(
294✔
788
                'div', $feedbackdiv, ['class' => 'formulaspartfeedback formulaspartfeedback-' . $part->partindex]
294✔
789
            );
294✔
790
        }
791

792
        // Still here? Then we return an empty string.
793
        return '';
189✔
794
    }
795

796
    /**
797
     * Generate HTML fragment for the part's combined feedback.
798
     *
799
     * @param question_attempt $qa question attempt that will be displayed on the page
800
     * @param question_display_options $options controls what should and should not be displayed
801
     * @param qtype_formulas_part $part question part
802
     * @param float $fraction the obtained grade
803
     * @return string HTML fragment
804
     */
805
    protected function part_combined_feedback(question_attempt $qa, question_display_options $options,
806
        qtype_formulas_part $part, float $fraction): string {
807
        $feedback = '';
588✔
808
        $showfeedback = false;
588✔
809
        /** @var qtype_formulas_question $question */
810
        $question = $qa->get_question();
588✔
811
        $state = $qa->get_state();
588✔
812
        $feedbackclass = $state->get_feedback_class();
588✔
813

814
        if ($qa->get_behaviour_name() == 'adaptivemultipart') {
588✔
815
            $details = $qa->get_behaviour()->get_part_mark_details($part->partindex);
105✔
816
            $feedbackclass = $details->state->get_feedback_class();
105✔
817
        } else {
818
            $state = question_state::graded_state_for_fraction($fraction);
504✔
819
            $feedbackclass = $state->get_feedback_class();
504✔
820
        }
821
        if ($feedbackclass != '') {
588✔
822
            $showfeedback = $options->feedback;
588✔
823
            $field = 'part' . $feedbackclass . 'fb';
588✔
824
            $format = 'part' . $feedbackclass . 'fbformat';
588✔
825
            if ($part->$field) {
588✔
826
                // Clone the part's evaluator and substitute local / grading vars first.
827
                $part->$field = $part->evaluator->substitute_variables_in_text($part->$field);
567✔
828
                $feedback = $question->format_text($part->$field, $part->$format,
567✔
829
                        $qa, 'qtype_formulas', $field, $part->id, false);
567✔
830
            }
831
        }
832
        if ($showfeedback && $feedback) {
588✔
833
                $feedback = html_writer::tag('div', $feedback , ['class' => 'feedback formulaslocalfeedback']);
462✔
834
                return html_writer::nonempty_tag('div', $feedback,
462✔
835
                        ['class' => 'formulaspartfeedback formulaspartfeedback-' . $part->partindex]);
462✔
836
        }
837
        return '';
546✔
838
    }
839
}
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