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

FormulasQuestion / moodle-qtype_formulas / 24044503888

06 Apr 2026 06:21PM UTC coverage: 97.22% (-0.3%) from 97.498%
24044503888

Pull #264

github

web-flow
Merge d552dab53 into f8206cd28
Pull Request #264: Allow parts to have empty fields

80 of 92 new or added lines in 11 files covered. (86.96%)

3 existing lines in 1 file now uncovered.

4652 of 4785 relevant lines covered (97.22%)

959.31 hits per line

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

95.0
/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
use qtype_formulas\local\formulas_part;
18

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

27
/**
28
 * Base class for generating the bits of output for formulas questions.
29
 *
30
 * @copyright  2009 The Open University
31
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
32
 */
33
class qtype_formulas_renderer extends qtype_with_combined_feedback_renderer {
34
    /** @var string */
35
    const UNIT_FIELD = 'u';
36

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

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

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

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

87
        // Pack everything in a <div> and, if the question is in an invalid state, append the appropriate error message
88
        // at the very end.
89
        $result = html_writer::tag('div', $questiontext, ['class' => 'qtext']);
1,320✔
90
        if ($qa->get_state() == question_state::$invalid) {
1,320✔
91
            $result .= html_writer::nonempty_tag(
22✔
92
                'div',
22✔
93
                $question->get_validation_error($qa->get_last_qt_data()),
22✔
94
                ['class' => 'validationerror']
22✔
95
            );
22✔
96
        }
97

98
        return $result;
1,320✔
99
    }
100

101
    /**
102
     * Return HTML that needs to be included in the page's <head> when this
103
     * question is used.
104
     *
105
     * @param question_attempt $qa question attempt that will be displayed on the page
106
     * @return string HTML fragment
107
     */
108
    public function head_code(question_attempt $qa): string {
109
        $this->page->requires->js_call_amd(
×
110
            'qtype_formulas/answervalidation',
×
111
            'init',
×
112
            [get_config('qtype_formulas', 'debouncedelay')]
×
113
        );
×
114
        $this->page->requires->js_call_amd(
×
115
            'qtype_formulas/tooltip',
×
116
            'init',
×
117
        );
×
118

119
        return '';
×
120
    }
121

122
    /**
123
     * Return the part text, controls, grading details and feedbacks.
124
     *
125
     * @param question_attempt $qa question attempt that will be displayed on the page
126
     * @param question_display_options $options controls what should and should not be displayed
127
     * @param formulas_part $part question part
128
     * @return void
129
     */
130
    public function part_formulation_and_controls(
131
        question_attempt $qa,
132
        question_display_options $options,
133
        formulas_part $part
134
    ): string {
135

136
        // The behaviour might change the display options per part, so it is safer to clone them here.
137
        $partoptions = clone $options;
1,320✔
138
        if ($qa->get_behaviour_name() === 'adaptivemultipart') {
1,320✔
139
            $qa->get_behaviour()->adjust_display_options_for_part($part->partindex, $partoptions);
55✔
140
        }
141

142
        // Fetch information about the outcome: grade, feedback symbol, CSS class to be used.
143
        $outcomedata = $this->get_part_feedback_class_and_symbol($qa, $partoptions, $part);
1,320✔
144

145
        // First of all, we take the part's question text and its input fields.
146
        $output = $this->get_part_formulation($qa, $partoptions, $part, $outcomedata);
1,320✔
147

148
        // If the user has requested the feedback symbol to be placed at a special position, we
149
        // do that now. Otherwise, we just append it after the part's text and input boxes.
150
        if (strpos($output, '{_m}') !== false) {
1,320✔
151
            $output = str_replace('{_m}', $outcomedata->feedbacksymbol, $output);
×
152
        } else {
153
            $output .= $outcomedata->feedbacksymbol;
1,320✔
154
        }
155

156
        // The part's feedback consists of the combined feedback (correct, partially correct, incorrect -- depending on the
157
        // outcome) and the general feedback which is given in all cases.
158
        $feedback = $this->part_combined_feedback($qa, $partoptions, $part, $outcomedata->fraction);
1,320✔
159
        $feedback .= $this->part_general_feedback($qa, $partoptions, $part);
1,320✔
160

161
        // If requested, the correct answer should be appended to the feedback.
162
        if ($partoptions->rightanswer) {
1,320✔
163
            $feedback .= $this->part_correct_response($part);
198✔
164
        }
165

166
        // Put all feedback into a <div> with the appropriate CSS class and append it to the output.
167
        $output .= html_writer::nonempty_tag('div', $feedback, ['class' => 'formulaspartoutcome outcome']);
1,320✔
168

169
        return html_writer::tag('div', $output, ['class' => 'formulaspart']);
1,320✔
170
    }
171

172
    /**
173
     * Return class and symbol for the part feedback.
174
     *
175
     * @param question_attempt $qa question attempt that will be displayed on the page
176
     * @param question_display_options $options controls what should and should not be displayed
177
     * @param formulas_part $part question part
178
     * @return stdClass
179
     */
180
    public function get_part_feedback_class_and_symbol(
181
        question_attempt $qa,
182
        question_display_options $options,
183
        formulas_part $part
184
    ): stdClass {
185
        // Prepare a new object to hold the different elements.
186
        $result = new stdClass();
1,320✔
187

188
        // Fetch the last response data and grade it.
189
        $response = $qa->get_last_qt_data();
1,320✔
190
        ['answer' => $answergrade, 'unit' => $unitcorrect] = $part->grade($response);
1,320✔
191

192
        // The fraction will later be used to determine which feedback (correct, partially correct or incorrect)
193
        // to use. We have to take into account a possible deduction for a wrong unit.
194
        $result->fraction = $answergrade;
1,320✔
195
        if ($unitcorrect === false) {
1,320✔
196
            $result->fraction *= (1 - $part->unitpenalty);
803✔
197
        }
198

199
        // By default, we add no feedback at all...
200
        $result->feedbacksymbol = '';
1,320✔
201
        $result->feedbackclass = '';
1,320✔
202
        // ... unless correctness is requested in the display options.
203
        if ($options->correctness) {
1,320✔
204
            $result->feedbacksymbol = $this->feedback_image($result->fraction);
242✔
205
            $result->feedbackclass = $this->feedback_class($result->fraction);
242✔
206
        }
207
        return $result;
1,320✔
208
    }
209

210
    /**
211
     * Format given number according to numbering style, e. g. abc or 123.
212
     *
213
     * @param int $num number
214
     * @param string $style style to render the number in, acccording to {@see qtype_multichoice::get_numbering_styles()}
215
     * @return string number $num in the requested style
216
     */
217
    protected static function number_in_style(int $num, string $style): string {
218
        switch ($style) {
219
            case 'abc':
33✔
220
                $number = chr(ord('a') + $num);
22✔
221
                break;
22✔
222
            case 'ABCD':
11✔
223
                $number = chr(ord('A') + $num);
×
224
                break;
×
225
            case '123':
11✔
226
                $number = $num + 1;
×
227
                break;
×
228
            case 'iii':
11✔
229
                $number = question_utils::int_to_roman($num + 1);
×
230
                break;
×
231
            case 'IIII':
11✔
232
                $number = strtoupper(question_utils::int_to_roman($num + 1));
×
233
                break;
×
234
            case 'none':
11✔
235
                return '';
×
236
            default:
237
                // Default similar to none for compatibility with old questions.
238
                return '';
11✔
239
        }
240
        return $number . '. ';
22✔
241
    }
242

243
    /**
244
     * Create a set of radio boxes for a multiple choice answer input.
245
     *
246
     * @param formulas_part $part question part
247
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
248
     * @param question_attempt $qa question attempt that will be displayed on the page
249
     * @param array $answeroptions array of strings containing the answer options to choose from
250
     * @param bool $shuffle whether the options should be shuffled
251
     * @param question_display_options $displayoptions controls what should and should not be displayed
252
     * @param string $feedbackclass
253
     * @return string HTML fragment
254
     */
255
    protected function create_radio_mc_answer(
256
        formulas_part $part,
257
        $answerindex,
258
        question_attempt $qa,
259
        array $answeroptions,
260
        bool $shuffle,
261
        question_display_options $displayoptions,
262
        string $feedbackclass = ''
263
    ): string {
264
        /** @var qtype_formulas_question $question */
265
        $question = $qa->get_question();
33✔
266

267
        $variablename = "{$part->partindex}_{$answerindex}";
33✔
268
        $currentanswer = $qa->get_last_qt_var($variablename);
33✔
269
        $inputname = $qa->get_qt_field_name($variablename);
33✔
270

271
        $inputattributes['type'] = 'radio';
33✔
272
        $inputattributes['name'] = $inputname;
33✔
273
        if ($displayoptions->readonly) {
33✔
274
            $inputattributes['disabled'] = 'disabled';
11✔
275
        }
276

277
        // First, we open a <fieldset> around the entire group of options.
278
        $output = html_writer::start_tag('fieldset', ['class' => 'multichoice_answer']);
33✔
279

280
        // Inside the fieldset, we put the accessibility label, following the example of core's multichoice
281
        // question type, i. e. the label is inside a <span> with class 'sr-only', wrapped in a <legend>.
282
        // TODO: we should use visually-hidden after dropping Moodle 4.5.
283
        $output .= html_writer::start_tag('legend', ['class' => 'sr-only']);
33✔
284
        $output .= html_writer::span(
33✔
285
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
33✔
286
            'sr-only'
33✔
287
        );
33✔
288
        $output .= html_writer::end_tag('legend');
33✔
289

290
        // If needed, shuffle the options while maintaining the keys.
291
        if ($shuffle) {
33✔
292
            $keys = array_keys($answeroptions);
11✔
293
            shuffle($keys);
11✔
294

295
            $shuffledoptions = [];
11✔
296
            foreach ($keys as $key) {
11✔
297
                $shuffledoptions[$key] = $answeroptions[$key];
11✔
298
            }
299
            $answeroptions = $shuffledoptions;
11✔
300
        }
301

302
        // Iterate over all options.
303
        foreach ($answeroptions as $i => $optiontext) {
33✔
304
            $numbering = html_writer::span(self::number_in_style($i, $question->answernumbering), 'answernumber');
33✔
305
            $labeltext = $question->format_text(
33✔
306
                $numbering . $optiontext,
33✔
307
                $part->subqtextformat,
33✔
308
                $qa,
33✔
309
                'qtype_formulas',
33✔
310
                'answersubqtext',
33✔
311
                $part->id,
33✔
312
                false,
33✔
313
            );
33✔
314

315
            $inputattributes['id'] = $inputname . '_' . $i;
33✔
316
            $inputattributes['value'] = $i;
33✔
317
            // Class ml-3 is Bootstrap's class for margin-left: 1rem; it used to be m-l-1.
318
            $label = $this->create_label_for_input($labeltext, $inputattributes['id'], ['class' => 'ml-3']);
33✔
319
            $inputattributes['aria-labelledby'] = $label['id'];
33✔
320

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

326
            // We do not reset the $inputattributes array on each iteration, so we have to add/remove the
327
            // attribute every time.
328
            if ($isselected) {
33✔
329
                $inputattributes['checked'] = 'checked';
11✔
330
            } else {
331
                unset($inputattributes['checked']);
33✔
332
            }
333

334
            // Each option (radio box element plus label) is wrapped in its own <div> element.
335
            $divclass = 'r' . ($i % 2);
33✔
336
            if ($displayoptions->correctness && $isselected) {
33✔
337
                $divclass .= ' ' . $feedbackclass;
11✔
338
            }
339
            $output .= html_writer::start_div($divclass);
33✔
340

341
            // Now add the <input> tag and its <label>.
342
            $output .= html_writer::empty_tag('input', $inputattributes);
33✔
343
            $output .= $label['html'];
33✔
344

345
            // Close the option's <div>.
346
            $output .= html_writer::end_div();
33✔
347
        }
348

349
        // Close the option group's <fieldset>.
350
        $output .= html_writer::end_tag('fieldset');
33✔
351

352
        return $output;
33✔
353
    }
354

355
    /**
356
     * Translate an array containing formatting options into a CSS format string, e. g. from
357
     * ['w' => '50px', 'bgcol' => 'yellow'] to 'width: 50px; background-color: yellow'. Note:
358
     * - colors can be defined in 3 or 6 digit hex RGB, in 4 or 8 digit hex RGBA or as CSS named color
359
     * - widths can be defined as a number followed by the units px, rem or em; if the unit is omitted, rem will be used
360
     * - alignment can be defined as left, right, center, start or end
361
     *
362
     * @param array $options associative array containing options (in our own denomination) and their settings
363
     * @return string
364
     */
365
    protected function get_css_properties(array $options): string {
366
        // Define some regex pattern.
367
        $hexcolor = '#([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{3}|[0-9A-F]{4})';
1,276✔
368
        $namedcolor = '[A-Z]+';
1,276✔
369
        // We accept floating point numbers with or without a leading integer part and integers.
370
        // Floating point numbers with a trailing decimal point do not work in all browsers.
371
        $length = '(\d+\.\d+|\d*\.\d+|\d+)(px|em|rem)?';
1,276✔
372
        $alignment = 'start|end|left|right|center';
1,276✔
373

374
        $styles = [];
1,276✔
375
        foreach ($options as $name => $value) {
1,276✔
376
            switch ($name) {
377
                case 'bgcol':
1,276✔
378
                    if (!preg_match("/^(($hexcolor)|($namedcolor))$/i", $value)) {
319✔
379
                        break;
22✔
380
                    }
381
                    $styles[] = "background-color: $value";
297✔
382
                    break;
297✔
383
                case 'txtcol':
1,276✔
384
                    if (!preg_match("/^(($hexcolor)|($namedcolor))$/i", $value)) {
132✔
385
                        break;
22✔
386
                    }
387
                    $styles[] = "color: $value";
110✔
388
                    break;
110✔
389
                case 'w':
1,276✔
390
                    if (!preg_match("/^($length)$/i", $value)) {
1,276✔
391
                        break;
88✔
392
                    }
393
                    // If no unit is given, append rem.
394
                    $styles[] = "width: $value" . (preg_match('/\d$/', $value) ? 'rem' : '');
1,232✔
395
                    break;
1,232✔
396
                case 'align':
143✔
397
                    if (!preg_match("/^($alignment)$/i", $value)) {
143✔
398
                        break;
22✔
399
                    }
400
                    $styles[] = "text-align: $value";
121✔
401
                    break;
121✔
402
            }
403
        }
404

405
        return implode(';', $styles);
1,276✔
406
    }
407

408
    /**
409
     * Create a <label> element for a given input control (e. g. a text field). Returns the
410
     * HTML and the label's ID.
411
     *
412
     * @param string $text the label's text
413
     * @param string $inputid ID of the input control for which the label is created
414
     * @param array $additionalattributes possibility to add custom attributes, attribute name => value
415
     * @return array 'id' => label's ID to be used in 'aria-labelledby' attribute, 'html' => HTML code
416
     */
417
    protected function create_label_for_input(string $text, string $inputid, array $additionalattributes = []): array {
418
        $labelid = 'lbl_' . str_replace(':', '__', $inputid);
1,320✔
419
        $attributes = [
1,320✔
420
            'class' => 'subq sr-only',
1,320✔
421
            'for' => $inputid,
1,320✔
422
            'id' => $labelid,
1,320✔
423
        ];
1,320✔
424

425
        // Merging the additional attributes with the default attributes; left array has precedence when
426
        // using the + operator.
427
        $attributes = $additionalattributes + $attributes;
1,320✔
428

429
        return [
1,320✔
430
            'id' => $labelid,
1,320✔
431
            'html' => html_writer::tag('label', $text, $attributes),
1,320✔
432
        ];
1,320✔
433
    }
434

435
    /**
436
     * Create a <select> field for a multiple choice answer input.
437
     *
438
     * @param formulas_part $part question part
439
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
440
     * @param question_attempt $qa question attempt that will be displayed on the page
441
     * @param array $answeroptions array of strings containing the answer options to choose from
442
     * @param bool $shuffle whether the options should be shuffled
443
     * @param question_display_options $displayoptions controls what should and should not be displayed
444
     * @return string HTML fragment
445
     */
446
    protected function create_dropdown_mc_answer(
447
        formulas_part $part,
448
        $answerindex,
449
        question_attempt $qa,
450
        array $answeroptions,
451
        bool $shuffle,
452
        question_display_options $displayoptions
453
    ): string {
454
        /** @var qtype_formulas_question $question */
455
        $question = $qa->get_question();
33✔
456

457
        $variablename = "{$part->partindex}_{$answerindex}";
33✔
458
        $currentanswer = $qa->get_last_qt_var($variablename);
33✔
459
        $inputname = $qa->get_qt_field_name($variablename);
33✔
460

461
        $inputattributes['name'] = $inputname;
33✔
462
        $inputattributes['value'] = $currentanswer;
33✔
463
        $inputattributes['id'] = $inputname;
33✔
464
        $inputattributes['class'] = 'formulas-select';
33✔
465

466
        $label = $this->create_label_for_input(
33✔
467
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
33✔
468
            $inputname
33✔
469
        );
33✔
470
        $inputattributes['aria-labelledby'] = $label['id'];
33✔
471

472
        if ($displayoptions->readonly) {
33✔
473
            $inputattributes['disabled'] = 'disabled';
11✔
474
        }
475

476
        // First, we open a <span> around the dropdown field and its accessibility label.
477
        $output = html_writer::start_tag('span', ['class' => 'formulas_menu']);
33✔
478
        $output .= $label['html'];
33✔
479

480
        // Iterate over all options.
481
        $entries = [];
33✔
482
        foreach ($answeroptions as $optiontext) {
33✔
483
            $entries[] = $question->format_text(
33✔
484
                $optiontext,
33✔
485
                $part->subqtextformat,
33✔
486
                $qa,
33✔
487
                'qtype_formulas',
33✔
488
                'answersubqtext',
33✔
489
                $part->id,
33✔
490
                false,
33✔
491
            );
33✔
492
        }
493

494
        // If needed, shuffle the options while maintaining the keys.
495
        if ($shuffle) {
33✔
496
            $keys = array_keys($entries);
11✔
497
            shuffle($keys);
11✔
498

499
            $shuffledentries = [];
11✔
500
            foreach ($keys as $key) {
11✔
501
                $shuffledentries[$key] = $entries[$key];
11✔
502
            }
503
            $entries = $shuffledentries;
11✔
504
        }
505

506
        $output .= html_writer::select($entries, $inputname, $currentanswer, ['' => ''], $inputattributes);
33✔
507
        $output .= html_writer::end_tag('span');
33✔
508

509
        return $output;
33✔
510
    }
511

512
    /**
513
     * Generate the right label for the input control, depending on the number of answers in the given part
514
     * and the number of parts in the question. Special cases (combined field, unit field) are also taken
515
     * into account. Returns the appropriate string from the language file. Examples are "Answer field for
516
     * part X", "Answer field X for part Y" or "Answer and unit for part X".
517
     *
518
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
519
     * @param int $totalanswers number of answers for the given part
520
     * @param int $partindex number of the part (starting at 0) in this question
521
     * @param int $totalparts number of parts in the question
522
     * @return string localized string
523
     */
524
    protected function generate_accessibility_label_text(
525
        $answerindex,
526
        int $totalanswers,
527
        int $partindex,
528
        int $totalparts
529
    ): string {
530

531
        // Some language strings need parameters.
532
        $labeldata = new stdClass();
1,320✔
533

534
        // The language strings start with 'answerunit' for a separate unit field, 'answercombinedunit' for
535
        // a combined field, 'answercoordinate' for an answer field when there are multiple answers in the
536
        // part or just 'answer' if there is a single field.
537
        $labelstring = 'answer';
1,320✔
538
        if ($answerindex === self::UNIT_FIELD) {
1,320✔
539
            $labelstring .= 'unit';
407✔
540
        } else if ($answerindex === self::COMBINED_FIELD) {
1,320✔
541
            $labelstring .= 'combinedunit';
440✔
542
        } else if ($totalanswers > 1) {
1,089✔
543
            $labelstring .= 'coordinate';
33✔
544
            $labeldata->numanswer = $answerindex + 1;
33✔
545
        }
546

547
        // The language strings end with 'multi' for multi-part questions or 'single' for single-part
548
        // questions.
549
        if ($totalparts > 1) {
1,320✔
550
            $labelstring .= 'multi';
55✔
551
            $labeldata->part = $partindex + 1;
55✔
552
        } else {
553
            $labelstring .= 'single';
1,287✔
554
        }
555

556
        return get_string($labelstring, 'qtype_formulas', $labeldata);
1,320✔
557
    }
558

559
    /**
560
     * Create an <input> field.
561
     *
562
     * @param formulas_part $part question part
563
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
564
     * @param question_attempt $qa question attempt that will be displayed on the page
565
     * @param question_display_options $displayoptions controls what should and should not be displayed
566
     * @param array $formatoptions associative array 'optionname' => 'value', e. g. 'w' => '50px'
567
     * @param string $feedbackclass
568
     * @return string HTML fragment
569
     */
570
    protected function create_input_box(
571
        formulas_part $part,
572
        $answerindex,
573
        question_attempt $qa,
574
        question_display_options $displayoptions,
575
        array $formatoptions = [],
576
        string $feedbackclass = ''
577
    ): string {
578
        /** @var qtype_formulas_question $question */
579
        $question = $qa->get_question();
1,276✔
580

581
        // The variable name will be N_ for the (single) combined unit field of part N,
582
        // or N_M for answer #M in part #N. If #M is equal to the part's numbox (i. e. the
583
        // number of answers), it is a unit field; note that the fields are numbered starting
584
        // from 0, so with 3 answers, we have N_0, N_1, N_2 and only use N_3 if there is a
585
        // unit.
586
        $variablename = $part->partindex . '_';
1,276✔
587
        if ($answerindex === self::UNIT_FIELD) {
1,276✔
588
            $variablename .= $part->numbox;
407✔
589
        } else {
590
            $variablename .= ($answerindex === self::COMBINED_FIELD ? '' : $answerindex);
1,276✔
591
        }
592

593
        $currentanswer = $qa->get_last_qt_var($variablename);
1,276✔
594
        $inputname = $qa->get_qt_field_name($variablename);
1,276✔
595

596
        // Text fields will have a tooltip attached. The tooltip's content depends on the
597
        // answer type. Special tooltips exist for combined or separate unit fields.
598
        switch ($part->answertype) {
1,276✔
599
            case qtype_formulas::ANSWER_TYPE_NUMERIC:
600
                $titlestring = 'numeric';
66✔
601
                break;
66✔
602
            case qtype_formulas::ANSWER_TYPE_NUMERICAL_FORMULA:
603
                $titlestring = 'numerical_formula';
66✔
604
                break;
66✔
605
            case qtype_formulas::ANSWER_TYPE_ALGEBRAIC:
606
                $titlestring = 'algebraic_formula';
88✔
607
                break;
88✔
608
            case qtype_formulas::ANSWER_TYPE_NUMBER:
609
            default:
610
                $titlestring = 'number';
1,100✔
611
        }
612
        if ($answerindex === self::COMBINED_FIELD) {
1,276✔
613
            $titlestring .= '_unit';
440✔
614
        }
615
        if ($answerindex === self::UNIT_FIELD) {
1,276✔
616
            $titlestring = 'unit';
407✔
617
        }
618
        $title = get_string($titlestring, 'qtype_formulas');
1,276✔
619

620
        // Fetch the configured default width and use it, if the setting exists. Otherwise,
621
        // the plugin's default as defined in styles.css will be used. If the user did not
622
        // specify a unit, we use pixels (px). Note that this is different from the renderer
623
        // where rem is used in order to allow for the short syntax 'w=3' (3 chars wide).
624
        $defaultformat = [];
1,276✔
625
        $defaultwidth = get_config('qtype_formulas', "defaultwidth_{$titlestring}");
1,276✔
626
        // If the default width has not been set for the current answer box type, $defaultwidth will
627
        // be false and thus not numeric.
628
        if (is_numeric($defaultwidth)) {
1,276✔
629
            $defaultwidthunit = get_config('qtype_formulas', "defaultwidthunit");
1,276✔
630
            if (!in_array($defaultwidthunit, ['px', 'rem', 'em'])) {
1,276✔
631
                $defaultwidthunit = 'px';
88✔
632
            }
633
            $defaultformat = ['w' => $defaultwidth . $defaultwidthunit];
1,276✔
634
        }
635
        // Using the union operator the values from the left array will be kept.
636
        $formatoptions = $formatoptions + $defaultformat;
1,276✔
637

638
        $inputattributes = [
1,276✔
639
            'type' => 'text',
1,276✔
640
            'name' => $inputname,
1,276✔
641
            'value' => $currentanswer,
1,276✔
642
            'id' => $inputname,
1,276✔
643
            'style' => $this->get_css_properties($formatoptions),
1,276✔
644

645
            'data-answertype' => ($answerindex === self::UNIT_FIELD ? 'unit' : $part->answertype),
1,276✔
646
            'data-withunit' => ($answerindex === self::COMBINED_FIELD ? '1' : '0'),
1,276✔
647

648
            'title' => $title,
1,276✔
649
            'class' => "form-control formulas_{$titlestring} {$feedbackclass}",
1,276✔
650
            'maxlength' => 128,
1,276✔
651
        ];
1,276✔
652

653
        // If the answer type is "Number" and it is not a combined field, we only add the tooltip, if the
654
        // corresponding option is set.
655
        $iscombined = $inputattributes['data-withunit'] === '1';
1,276✔
656
        $isnumber = !$iscombined && $inputattributes['data-answertype'] === qtype_formulas::ANSWER_TYPE_NUMBER;
1,276✔
657
        $shownumbertooltip = get_config('qtype_formulas', 'shownumbertooltip');
1,276✔
658
        $inputattributes += [
1,276✔
659
            'data-qtype-formulas-enable-tooltip' => (!$isnumber || $shownumbertooltip ? 'true' : 'false'),
1,276✔
660
        ];
1,276✔
661

662
        if ($displayoptions->readonly) {
1,276✔
663
            $inputattributes['readonly'] = 'readonly';
187✔
664
        }
665

666
        $label = $this->create_label_for_input(
1,276✔
667
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
1,276✔
668
            $inputname
1,276✔
669
        );
1,276✔
670
        $inputattributes['aria-labelledby'] = $label['id'];
1,276✔
671

672
        // We need to wrap our input field into a wrapper <div>, in order for the LaTeX preview
673
        // to be correctly positioned even inside a table.
674
        $output = $label['html'];
1,276✔
675
        $output .= html_writer::empty_tag('input', $inputattributes);
1,276✔
676

677
        return $output;
1,276✔
678
    }
679

680
    /**
681
     * Return the part's text with variables replaced by their values.
682
     *
683
     * @param question_attempt $qa question attempt that will be displayed on the page
684
     * @param question_display_options $options controls what should and should not be displayed
685
     * @param formulas_part $part question part
686
     * @param stdClass $sub class and symbol for the part feedback
687
     * @return string HTML fragment
688
     */
689
    public function get_part_formulation(
690
        question_attempt $qa,
691
        question_display_options $options,
692
        formulas_part $part,
693
        stdClass $sub
694
    ): string {
695
        /** @var qtype_formulas_question $question */
696
        $question = $qa->get_question();
1,320✔
697

698
        // Clone the part's evaluator and remove special variables like _0 etc., because they must
699
        // not be substituted here; otherwise, we would lose input boxes.
700
        $evaluator = clone $part->evaluator;
1,320✔
701
        $evaluator->remove_special_vars();
1,320✔
702
        $text = $evaluator->substitute_variables_in_text($part->subqtext);
1,320✔
703

704
        $subqreplaced = $question->format_text(
1,320✔
705
            $text,
1,320✔
706
            $part->subqtextformat,
1,320✔
707
            $qa,
1,320✔
708
            'qtype_formulas',
1,320✔
709
            'answersubqtext',
1,320✔
710
            $part->id,
1,320✔
711
            false,
1,320✔
712
        );
1,320✔
713

714
        // Get the set of defined placeholders and their options.
715
        $boxes = $part->scan_for_answer_boxes($subqreplaced);
1,320✔
716

717
        // Append missing placholders at the end of part. We do not put a space before the opening
718
        // or after the closing brace, in order to get {_0}{_u} for questions with one answer and
719
        // a unit. This makes sure that the question will receive a combined unit field.
720
        for ($i = 0; $i <= $part->numbox; $i++) {
1,320✔
721
            // If no unit has been set, we do not append the {_u} placeholder.
722
            if ($i == $part->numbox && empty($part->postunit)) {
1,320✔
723
                continue;
737✔
724
            }
725
            $placeholder = ($i == $part->numbox) ? '_u' : "_{$i}";
1,320✔
726
            // If the placeholder does not exist yet, we create it with default settings, i. e. no multi-choice
727
            // and no styling.
728
            if (!array_key_exists($placeholder, $boxes)) {
1,320✔
729
                $boxes[$placeholder] = [
319✔
730
                    'placeholder' => '{' . $placeholder . '}',
319✔
731
                    'options' => '',
319✔
732
                    'dropdown' => false,
319✔
733
                    'format' => [],
319✔
734
                ];
319✔
735
                $subqreplaced .= '{' . $placeholder . '}';
319✔
736
            }
737
        }
738

739
        // If part has combined unit answer input.
740
        if ($part->has_combined_unit_field()) {
1,320✔
741
            // For a combined unit field, we try to merge the formatting options from the {_0} and the
742
            // {_u} placeholder, giving precedence to the latter.
743
            $mergedformat = $boxes['_u']['format'] + $boxes['_0']['format'];
440✔
744
            $combinedfieldhtml = $this->create_input_box(
440✔
745
                $part,
440✔
746
                self::COMBINED_FIELD,
440✔
747
                $qa,
440✔
748
                $options,
440✔
749
                $mergedformat,
440✔
750
                $sub->feedbackclass,
440✔
751
            );
440✔
752
            // The combined field must be placed where the user has the {_0}{_u} placeholders, possibly with
753
            // their formatting options.
754
            $boxplaceholders = $boxes['_0']['placeholder'] . $boxes['_u']['placeholder'];
440✔
755
            return str_replace($boxplaceholders, $combinedfieldhtml, $subqreplaced);
440✔
756
        }
757

758
        // Iterate over all boxes again, this time creating the appropriate input control and insert it
759
        // at the position indicated by the placeholder.
760
        for ($i = 0; $i <= $part->numbox; $i++) {
1,089✔
761
            // For normal answer fields, the placeholder is {_N} with N being the number of the
762
            // answer, starting from 0. The unit field, if there is one, comes last and has the
763
            // {_u} placeholder.
764
            if ($i < $part->numbox) {
1,089✔
765
                $answerindex = $i;
1,089✔
766
                $placeholder = "_$i";
1,089✔
767
            } else if (!empty($part->postunit)) {
1,089✔
768
                $answerindex = self::UNIT_FIELD;
407✔
769
                $placeholder = '_u';
407✔
770
            }
771

772
            // If the user has requested a multi-choice element, they must have specified an array
773
            // variable containing the options. We try to fetch that variable. If this fails, we
774
            // simply continue and build a text field instead.
775
            $optiontexts = null;
1,089✔
776
            if (!empty($boxes[$placeholder]['options'])) {
1,089✔
777
                try {
778
                    $optiontexts = $part->evaluator->export_single_variable($boxes[$placeholder]['options']);
77✔
779
                } catch (Exception $e) {
11✔
780
                    // TODO: use non-capturing catch.
781
                    unset($e);
11✔
782
                }
783
            }
784

785
            if ($optiontexts === null) {
1,089✔
786
                $inputfieldhtml = $this->create_input_box(
1,045✔
787
                    $part,
1,045✔
788
                    $answerindex,
1,045✔
789
                    $qa,
1,045✔
790
                    $options,
1,045✔
791
                    $boxes[$placeholder]['format'],
1,045✔
792
                    $sub->feedbackclass,
1,045✔
793
                );
1,045✔
794
            } else if ($boxes[$placeholder]['dropdown']) {
66✔
795
                $inputfieldhtml = $this->create_dropdown_mc_answer(
33✔
796
                    $part,
33✔
797
                    $i,
33✔
798
                    $qa,
33✔
799
                    $optiontexts->value,
33✔
800
                    $boxes[$placeholder]['shuffle'],
33✔
801
                    $options,
33✔
802
                );
33✔
803
            } else {
804
                $inputfieldhtml = $this->create_radio_mc_answer(
33✔
805
                    $part,
33✔
806
                    $i,
33✔
807
                    $qa,
33✔
808
                    $optiontexts->value,
33✔
809
                    $boxes[$placeholder]['shuffle'],
33✔
810
                    $options,
33✔
811
                    $sub->feedbackclass,
33✔
812
                );
33✔
813
            }
814

815
            // The replacement text *might* contain a backslash and in the worst case this might
816
            // lead to an erroneous backreference, e. g. if the student's answer was \1. Thus,
817
            // we better use preg_replace_callback() instead of just preg_replace(), as this allows
818
            // us to ignore such unintentional backreferences.
819
            $subqreplaced = preg_replace_callback(
1,089✔
820
                '/' . preg_quote($boxes[$placeholder]['placeholder'], '/') . '/',
1,089✔
821
                function ($matches) use ($inputfieldhtml) {
1,089✔
822
                    return $inputfieldhtml;
1,089✔
823
                },
1,089✔
824
                $subqreplaced,
1,089✔
825
                1
1,089✔
826
            );
1,089✔
827
        }
828

829
        return $subqreplaced;
1,089✔
830
    }
831

832
    /**
833
     * Correct response for the question. This is not needed for the Formulas question, because
834
     * answers are relative to parts.
835
     *
836
     * @param question_attempt $qa question attempt that will be displayed on the page
837
     * @return string empty string
838
     */
839
    public function correct_response(question_attempt $qa) {
840
        return '';
198✔
841
    }
842

843
    /**
844
     * Generate an automatic description of the correct response for a given part.
845
     *
846
     * @param formulas_part $part question part
847
     * @return string HTML fragment
848
     */
849
    public function part_correct_response($part) {
850
        $answers = $part->get_correct_response(true);
198✔
851
        foreach ($answers as &$answer) {
198✔
852
            if ($answer === '') {
198✔
NEW
853
                $answer = get_string('emptyanswer', 'qtype_formulas');
×
854
            }
855
        }
856
        $answertext = implode('; ', $answers);
198✔
857

858
        $string = ($part->answernotunique ? 'correctansweris' : 'uniquecorrectansweris');
198✔
859
        return html_writer::nonempty_tag(
198✔
860
            'div',
198✔
861
            get_string($string, 'qtype_formulas', $answertext),
198✔
862
            ['class' => 'formulaspartcorrectanswer filter_mathjaxloader_equation'],
198✔
863
        );
198✔
864
    }
865

866
    /**
867
     * Generate a brief statement of how many sub-parts of this question the
868
     * student got right.
869
     *
870
     * @param question_attempt $qa question attempt that will be displayed on the page
871
     * @return string HTML fragment
872
     */
873
    protected function num_parts_correct(question_attempt $qa) {
874
        /** @var qtype_formulas_question $question */
875
        $question = $qa->get_question();
132✔
876
        $response = $qa->get_last_qt_data();
132✔
877
        if (!$question->is_gradable_response($response) || array_key_exists('_seed', $response)) {
132✔
878
            return '';
55✔
879
        }
880

881
        $numright = $question->get_num_parts_right($response)[0];
132✔
882
        if ($numright === 1) {
132✔
883
            return get_string('yougotoneright', 'qtype_formulas');
22✔
884
        } else {
885
            return get_string('yougotnright', 'qtype_formulas', $numright);
121✔
886
        }
887
    }
888

889
    /**
890
     * We need to owerwrite this method to replace global variables by their value.
891
     *
892
     * @param question_attempt $qa question attempt that will be displayed on the page
893
     * @param question_hint $hint the hint to be shown
894
     * @return string HTML fragment
895
     */
896
    protected function hint(question_attempt $qa, question_hint $hint) {
897
        /** @var qtype_formulas_question $question */
898
        $question = $qa->get_question();
22✔
899
        $hint->hint = $question->evaluator->substitute_variables_in_text($hint->hint);
22✔
900

901
        return html_writer::nonempty_tag('div', $question->format_hint($hint, $qa), ['class' => 'hint']);
22✔
902
    }
903

904
    /**
905
     * Generate HTML fragment for the question's combined feedback.
906
     *
907
     * @param question_attempt $qa question attempt that will be displayed on the page
908
     * @return string HTML fragment
909
     */
910
    protected function combined_feedback(question_attempt $qa) {
911
        /** @var qtype_formulas_question $question */
912
        $question = $qa->get_question();
253✔
913

914
        $state = $qa->get_state();
253✔
915
        if (!$state->is_finished()) {
253✔
916
            $response = $qa->get_last_qt_data();
77✔
917
            // When starting a question, there will be no response, but the _seed variable will be set.
918
            // As we now accept questions with an empty response, the grading mechanism would try to grade
919
            // this "startup data" like an entirely empty response and hence give the feedback for a wrong
920
            // answer, before the student has even submitted anything.
921
            if (!$question->is_gradable_response($response) || array_key_exists('_seed', $response)) {
77✔
922
                return '';
55✔
923
            }
924
            $state = $question->grade_response($response)[1];
77✔
925
        }
926

927
        // The feedback will be in ->correctfeedback, ->partiallycorrectfeedback or ->incorrectfeedback,
928
        // with the corresponding ->...feedbackformat setting. We create the property names here to simplify
929
        // access.
930
        $fieldname = $state->get_feedback_class() . 'feedback';
253✔
931
        $formatname = $state->get_feedback_class() . 'feedbackformat';
253✔
932

933
        // If there is no feedback, we return an empty string.
934
        if (strlen(trim($question->$fieldname)) === 0) {
253✔
935
            return '';
×
936
        }
937

938
        // Otherwise, we return the appropriate feedback. The text is run through format_text() to have
939
        // variables replaced.
940
        return $question->format_text(
253✔
941
            $question->$fieldname,
253✔
942
            $question->$formatname,
253✔
943
            $qa,
253✔
944
            'question',
253✔
945
            $fieldname,
253✔
946
            $question->id,
253✔
947
            false,
253✔
948
        );
253✔
949
    }
950

951
    /**
952
     * Generate the specific feedback. This is feedback that varies according to
953
     * the response the student gave.
954
     *
955
     * @param question_attempt $qa question attempt that will be displayed on the page
956
     * @return string
957
     */
958
    public function specific_feedback(question_attempt $qa) {
959
        return $this->combined_feedback($qa);
253✔
960
    }
961

962
    /**
963
     * Gereate the part's general feedback. This is feedback is shown to all students.
964
     *
965
     * @param question_attempt $qa question attempt that will be displayed on the page
966
     * @param question_display_options $options controls what should and should not be displayed
967
     * @param formulas_part $part question part
968
     * @return string HTML fragment
969
     */
970
    protected function part_general_feedback(question_attempt $qa, question_display_options $options, formulas_part $part) {
971
        /** @var qtype_formulas_question $question */
972
        $question = $qa->get_question();
1,320✔
973
        $state = $qa->get_state();
1,320✔
974

975
        // If no feedback should be shown, we return an empty string.
976
        if (!$options->feedback) {
1,320✔
977
            return '';
1,298✔
978
        }
979

980
        // If we use the adaptive multipart behaviour, there will be some feedback about the grading,
981
        // e. g. the obtained marks for this submission and the attracted penalty.
982
        $gradingdetailsdiv = '';
253✔
983
        if ($qa->get_behaviour_name() == 'adaptivemultipart') {
253✔
984
            // This is rather a hack, but it will probably work.
985
            $renderer = $this->page->get_renderer('qbehaviour_adaptivemultipart');
55✔
986
            $details = $qa->get_behaviour()->get_part_mark_details($part->partindex);
55✔
987
            $gradingdetailsdiv = $renderer->render_adaptive_marks($details, $options);
55✔
988
            $state = $details->state;
55✔
989
        }
990
        // If the question is in a state that does not yet allow to give a feedback,
991
        // we return an empty string.
992
        if (empty($state->get_feedback_class())) {
253✔
993
            return '';
22✔
994
        }
995

996
        // If we have a general feedback, we substitute local / grading variables and
997
        // wrap it in a <div>.
998
        $feedbackdiv = '';
242✔
999
        if (strlen(trim($part->feedback)) !== 0) {
242✔
1000
            $feedbacktext = $part->evaluator->substitute_variables_in_text($part->feedback);
143✔
1001
            $feedbacktext = $question->format_text(
143✔
1002
                $feedbacktext,
143✔
1003
                FORMAT_HTML,
143✔
1004
                $qa,
143✔
1005
                'qtype_formulas',
143✔
1006
                'answerfeedback',
143✔
1007
                $part->id,
143✔
1008
                false
143✔
1009
            );
143✔
1010
            $feedbackdiv = html_writer::tag('div', $feedbacktext, ['class' => 'feedback formulaslocalfeedback']);
143✔
1011
        }
1012

1013
        // Append the grading details, if they exist. If the result is not empty, wrap in
1014
        // a <div> and return.
1015
        $feedbackdiv .= $gradingdetailsdiv;
242✔
1016
        if (!empty($feedbackdiv)) {
242✔
1017
            return html_writer::nonempty_tag(
154✔
1018
                'div',
154✔
1019
                $feedbackdiv,
154✔
1020
                ['class' => 'formulaspartfeedback formulaspartfeedback-' . $part->partindex],
154✔
1021
            );
154✔
1022
        }
1023

1024
        // Still here? Then we return an empty string.
1025
        return '';
99✔
1026
    }
1027

1028
    /**
1029
     * Generate HTML fragment for the part's combined feedback.
1030
     *
1031
     * @param question_attempt $qa question attempt that will be displayed on the page
1032
     * @param question_display_options $options controls what should and should not be displayed
1033
     * @param formulas_part $part question part
1034
     * @param float $fraction the obtained grade
1035
     * @return string HTML fragment
1036
     */
1037
    protected function part_combined_feedback(
1038
        question_attempt $qa,
1039
        question_display_options $options,
1040
        formulas_part $part,
1041
        float $fraction
1042
    ): string {
1043
        $feedback = '';
1,320✔
1044
        $showfeedback = false;
1,320✔
1045
        /** @var qtype_formulas_question $question */
1046
        $question = $qa->get_question();
1,320✔
1047
        $state = $qa->get_state();
1,320✔
1048
        $feedbackclass = $state->get_feedback_class();
1,320✔
1049

1050
        if ($qa->get_behaviour_name() == 'adaptivemultipart') {
1,320✔
1051
            $details = $qa->get_behaviour()->get_part_mark_details($part->partindex);
55✔
1052
            $feedbackclass = $details->state->get_feedback_class();
55✔
1053
        } else {
1054
            $state = question_state::graded_state_for_fraction($fraction);
1,276✔
1055
            $feedbackclass = $state->get_feedback_class();
1,276✔
1056
        }
1057
        if ($feedbackclass != '') {
1,320✔
1058
            $showfeedback = $options->feedback;
1,320✔
1059
            $field = 'part' . $feedbackclass . 'fb';
1,320✔
1060
            $format = 'part' . $feedbackclass . 'fbformat';
1,320✔
1061
            if ($part->$field) {
1,320✔
1062
                // Clone the part's evaluator and substitute local / grading vars first.
1063
                $part->$field = $part->evaluator->substitute_variables_in_text($part->$field);
1,309✔
1064
                $feedback = $question->format_text(
1,309✔
1065
                    $part->$field,
1,309✔
1066
                    $part->$format,
1,309✔
1067
                    $qa,
1,309✔
1068
                    'qtype_formulas',
1,309✔
1069
                    $field,
1,309✔
1070
                    $part->id,
1,309✔
1071
                    false,
1,309✔
1072
                );
1,309✔
1073
            }
1074
        }
1075
        if ($showfeedback && $feedback) {
1,320✔
1076
                $feedback = html_writer::tag('div', $feedback, ['class' => 'feedback formulaslocalfeedback']);
242✔
1077
                return html_writer::nonempty_tag(
242✔
1078
                    'div',
242✔
1079
                    $feedback,
242✔
1080
                    ['class' => 'formulaspartfeedback formulaspartfeedback-' . $part->partindex],
242✔
1081
                );
242✔
1082
        }
1083
        return '';
1,298✔
1084
    }
1085
}
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