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

FormulasQuestion / moodle-qtype_formulas / 17019138792

17 Aug 2025 09:01AM UTC coverage: 97.399% (-0.2%) from 97.629%
17019138792

Pull #264

github

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

78 of 92 new or added lines in 10 files covered. (84.78%)

12 existing lines in 5 files now uncovered.

4381 of 4498 relevant lines covered (97.4%)

1618.81 hits per line

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

98.75
/question.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
 * Question definition class for the Formulas question type.
19
 *
20
 * @copyright 2010-2011 Hon Wai, Lau; 2023 Philipp Imhof
21
 * @author Hon Wai, Lau <lau65536@gmail.com>
22
 * @author Philipp Imhof
23
 * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24
 * @package qtype_formulas
25
 */
26

27

28

29

30
// TODO: rewrite input checker script for student answer and teacher's model answer / unit.
31

32
use qtype_formulas\answer_unit_conversion;
33
use qtype_formulas\local\answer_parser;
34
use qtype_formulas\local\evaluator;
35
use qtype_formulas\local\lexer;
36
use qtype_formulas\local\random_parser;
37
use qtype_formulas\local\parser;
38
use qtype_formulas\local\token;
39
use qtype_formulas\local\variable;
40
use qtype_formulas\unit_conversion_rules;
41

42
defined('MOODLE_INTERNAL') || die();
43

44
require_once($CFG->dirroot . '/question/type/formulas/questiontype.php');
×
45
require_once($CFG->dirroot . '/question/type/formulas/answer_unit.php');
×
46
require_once($CFG->dirroot . '/question/type/formulas/conversion_rules.php');
×
UNCOV
47
require_once($CFG->dirroot . '/question/behaviour/adaptivemultipart/behaviour.php');
×
48

49
/**
50
 * Base class for the Formulas question type.
51
 *
52
 * @copyright 2010-2011 Hon Wai, Lau; 2023 Philipp Imhof
53
 * @author Hon Wai, Lau <lau65536@gmail.com>
54
 * @author Philipp Imhof
55
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
56
 */
57
class qtype_formulas_question extends question_graded_automatically_with_countback
58
        implements question_automatically_gradable_with_multiple_parts {
59

60
    /** @var int seed used to initialize the RNG; needed to restore an attempt state */
61
    public int $seed;
62

63
    /** @var ?evaluator evaluator class, this is where the evaluation stuff happens */
64
    public ?evaluator $evaluator = null;
65

66
    /** @var string $varsrandom definition text for random variables, as entered in the edit form */
67
    public string $varsrandom;
68

69
    /** @var string $varsglobal definition text for the question's global variables, as entered in the edit form */
70
    public string $varsglobal;
71

72
    /** @var qtype_formulas_part[] parts of the question */
73
    public $parts = [];
74

75
    /** @var string numbering (if any) of answers */
76
    public string $answernumbering;
77

78
    /** @var int number of parts in this question, used e.g. by the renderer */
79
    public int $numparts;
80

81
    /**
82
     * @var string[] strings (one more than $numparts) containing fragments from the question's main text
83
     *               that surround the parts' subtexts; used by the renderer
84
     */
85
    public array $textfragments;
86

87
    /** @var string $correctfeedback combined feedback for correct answer */
88
    public string $correctfeedback;
89

90
    /** @var int $correctfeedbackformat format of combined feedback for correct answer */
91
    public int $correctfeedbackformat;
92

93
    /** @var string $partiallycorrectfeedback combined feedback for partially correct answer */
94
    public string $partiallycorrectfeedback;
95

96
    /** @var int $partiallycorrectfeedbackformat format of combined feedback for partially correct answer */
97
    public int $partiallycorrectfeedbackformat;
98

99
    /** @var string $incorrectfeedback combined feedback for in correct answer */
100
    public string $incorrectfeedback;
101

102
    /** @var int $incorrectfeedbackformat format of combined feedback for incorrect answer */
103
    public int $incorrectfeedbackformat;
104

105
    /**
106
     * Create the appropriate behaviour for an attempt at this question.
107
     *
108
     * @param question_attempt $qa
109
     * @param string $preferredbehaviour
110
     * @return question_behaviour
111
     */
112
    public function make_behaviour(question_attempt $qa, $preferredbehaviour) {
113
        // If the requested behaviour is 'adaptive' or 'adaptiveopenpenalty', we have to change it
114
        // to 'adaptivemultipart'.
115
        if (in_array($preferredbehaviour, ['adaptive', 'adaptiveopenpenalty'])) {
2,604✔
116
            return question_engine::make_behaviour('adaptivemultipart', $qa, $preferredbehaviour);
315✔
117
        }
118

119
        // Otherwise, pass it on to the parent class.
120
        return parent::make_behaviour($qa, $preferredbehaviour);
2,310✔
121
    }
122

123
    /**
124
     * Start a new attempt at this question. This method initializes and instantiates the
125
     * random variables. Also, we will store the seed of the RNG in order to allow restoring
126
     * the question later on. Finally, we initialize the evaluators for every part, because
127
     * they need the global and random variables from the main question.
128
     *
129
     * @param question_attempt_step $step the step of the {@see question_attempt()} being started
130
     * @param int $variant the variant requested, integer between 1 and {@see get_num_variants()} inclusive
131
     */
132
    public function start_attempt(question_attempt_step $step, $variant): void {
133
        // Take $variant as the seed, store it in the database (question_attempt_step_data)
134
        // and seed the PRNG with that value.
135
        $this->seed = $variant;
3,171✔
136
        $step->set_qt_var('_seed', $this->seed);
3,171✔
137

138
        // Create an empty evaluator, feed it with the random variables and instantiate
139
        // them.
140
        $this->evaluator = new evaluator();
3,171✔
141
        $randomparser = new random_parser($this->varsrandom);
3,171✔
142
        $this->evaluator->evaluate($randomparser->get_statements());
3,171✔
143
        $this->evaluator->instantiate_random_variables($this->seed);
3,171✔
144

145
        // Parse the definition of global variables and evaluate them, taking into account
146
        // the random variables.
147
        $globalparser = new parser($this->varsglobal, $randomparser->export_known_variables());
3,171✔
148
        $this->evaluator->evaluate($globalparser->get_statements());
3,171✔
149

150
        // For improved backwards-compatibility (allowing downgrade back to 5.x), we also store
151
        // the legacy qt vars '_randomsvars_text' (not a typo) and '_varsglobal' in the DB.
152
        $legacynote = "# Legacy entry for backwards compatibility only\r\n";
3,171✔
153
        $step->set_qt_var('_randomsvars_text', $legacynote . $this->evaluator->export_randomvars_for_step_data());
3,171✔
154
        $step->set_qt_var('_varsglobal', $legacynote . $this->varsglobal);
3,171✔
155

156
        // Set the question's $numparts property.
157
        $this->numparts = count($this->parts);
3,171✔
158

159
        // Finally, set up the parts' evaluators that evaluate the local variables.
160
        $this->initialize_part_evaluators();
3,171✔
161
    }
162

163
    /**
164
     * When reloading an in-progress {@see \question_attempt} from the database, restore the question's
165
     * state, i. e. make sure the random variables are instantiated with the same values again. For more
166
     * recent versions, we do this by restoring the seed. For legacy questions, the instantiated values
167
     * are stored in the database.
168
     *
169
     * @param question_attempt_step $step the step of the {@see \question_attempt} being loaded
170
     */
171
    public function apply_attempt_state(question_attempt_step $step): void {
172
        // Create an empty evaluator.
173
        $this->evaluator = new evaluator();
42✔
174

175
        // For backwards compatibility, we must check whether the attempt stems from
176
        // a legacy version or not. Recent versions only store the seed that is used
177
        // to initialize the RNG.
178
        if ($step->has_qt_var('_seed')) {
42✔
179
            // Fetch the seed, set up the random variables and instantiate them with
180
            // the stored seed.
181
            $this->seed = $step->get_qt_var('_seed');
42✔
182
            $parser = new random_parser($this->varsrandom);
42✔
183
            $this->evaluator->evaluate($parser->get_statements());
42✔
184
            $this->evaluator->instantiate_random_variables($this->seed);
42✔
185

186
            // Parse the definition of global variables and evaluate them, taking into account
187
            // the random variables.
188
            $globalparser = new parser($this->varsglobal, $parser->export_known_variables());
42✔
189
            $this->evaluator->evaluate($globalparser->get_statements());
42✔
190
        } else {
191
            // Fetch the stored definition of the previously instantiated random variables
192
            // and send them to the evaluator. They will be evaluated as *global* variables,
193
            // because there is no randomness anymore. The data was created by the old
194
            // variables:vstack_get_serialization() function, so we know that every statement
195
            // ends with a semicolon and we can simply concatenate random and global vars definition.
196
            $randominstantiated = $step->get_qt_var('_randomsvars_text');
21✔
197
            $this->varsglobal = $step->get_qt_var('_varsglobal');
21✔
198
            $parser = new parser($randominstantiated . $this->varsglobal);
21✔
199
            $this->evaluator->evaluate($parser->get_statements());
21✔
200
        }
201

202
        // Set the question's $numparts property.
203
        $this->numparts = count($this->parts);
42✔
204

205
        // Set up the parts' evaluator classes and evaluate their local variables.
206
        $this->initialize_part_evaluators();
42✔
207

208
        parent::apply_attempt_state($step);
42✔
209
    }
210

211
    /**
212
     * Generate a brief plain-text summary of this question to be used e.g. in reports. The summary
213
     * will contain the question text and all parts' texts (at the right place) with all their variables
214
     * substituted.
215
     *
216
     * @return string a plain text summary of this question.
217
     */
218
    public function get_question_summary(): string {
219
        // First, we take the main question text and substitute all the placeholders.
220
        $questiontext = $this->evaluator->substitute_variables_in_text($this->questiontext);
2,667✔
221
        $summary = $this->html_to_text($questiontext, $this->questiontextformat);
2,667✔
222

223
        // For every part, we clone the current evaluator, so each part gets the same base of
224
        // instantiated random and global variables. Then we use the evaluator to prepare the part's
225
        // text.
226
        foreach ($this->parts as $part) {
2,667✔
227
            $subqtext = $part->evaluator->substitute_variables_in_text($part->subqtext);
2,667✔
228
            $chunk = $this->html_to_text($subqtext, $part->subqtextformat);
2,667✔
229
            // If the part has a placeholder, we insert the part's text at the position of the
230
            // placeholder. Otherwise, we simply append it.
231
            if ($part->placeholder !== '') {
2,667✔
232
                $summary = str_replace("{{$part->placeholder}}", $chunk, $summary);
210✔
233
            } else {
234
                $summary .= $chunk;
2,457✔
235
            }
236
        }
237

238
        // For the question summary, it seems useful to simplify the answer box placeholders.
239
        $summary = preg_replace(
2,667✔
240
            '/\{(_u|_\d+)(:(_[A-Za-z]|[A-Za-z]\w*)(:(MC|MCE|MCES|MCS))?)?((\|[\w =#]*)*)\}/',
2,667✔
241
            '{\1}',
2,667✔
242
            $summary,
2,667✔
243
        );
2,667✔
244

245
        return $summary;
2,667✔
246
    }
247

248
    /**
249
     * Return the number of variants that exist for this question. This depends on the definition of
250
     * random variables, so we have to pass through the question's evaluator class. If there is no
251
     * evaluator, we return PHP_INT_MAX.
252
     *
253
     * @return int number of variants or PHP_INT_MAX
254
     */
255
    public function get_num_variants(): int {
256
        // If the question data has not been analyzed yet, we let Moodle
257
        // define the seed freely.
258
        if ($this->evaluator === null) {
21✔
259
            return PHP_INT_MAX;
21✔
260
        }
261
        return $this->evaluator->get_number_of_variants();
21✔
262
    }
263

264
    /**
265
     * This function is called, if the question is attempted in interactive mode with multiple tries *and*
266
     * if it is setup to clear incorrect responses for the next try. In this case, we clear *all* answer boxes
267
     * (including a possibly existing unit field) for any part that is not fully correct.
268
     *
269
     * @param array $response student's response
270
     * @return array same array, but with *all* answers of wrong parts being empty
271
     */
272
    public function clear_wrong_from_response(array $response): array {
273
        // Note: We do not globally normalize the answers, because that would split the answer from
274
        // a combined unit field into two separate fields, e.g. from 0_ into 0_0 and 0_1. This
275
        // will still work, because the form does not have the input fields 0_0 and 0_1, but it
276
        // seems strange to do that.
277

278
        // Call the corresponding function for each part and apply the union operator. Note that
279
        // the first argument takes precedence if a key exists in both arrays, so this will
280
        // replace all answers from $response that have been set in clear_from_response_if_wrong() and
281
        // keep all the others.
282
        foreach ($this->parts as $part) {
42✔
283
            $response = $part->clear_from_response_if_wrong($response) + $response;
42✔
284
        }
285

286
        return $response;
42✔
287
    }
288

289
    /**
290
     * Return the number of parts that have been correctly answered. The renderer will call this function
291
     * when the question is attempted in interactive mode with multiple tries *and* it is setup to show
292
     * the number of correct responses.
293
     *
294
     * @param array $response student's response
295
     * @return array array with [0] = number of correct parts and [1] = total number of parts
296
     */
297
    public function get_num_parts_right(array $response): array {
298
        // Normalize all student answers.
299
        $response = $this->normalize_response($response);
483✔
300

301
        $numcorrect = 0;
483✔
302
        foreach ($this->parts as $part) {
483✔
303
            list('answer' => $answercorrect, 'unit' => $unitcorrect) = $part->grade($response);
483✔
304

305
            if ($answercorrect >= 0.999 && $unitcorrect == true) {
483✔
306
                $numcorrect++;
273✔
307
            }
308
        }
309
        return [$numcorrect, $this->numparts];
483✔
310
    }
311

312
    /**
313
     * Return the expected fields and data types for all answer boxes of the question. For every
314
     * answer box, we have one entry named "i_j" with i being the part's index and j being the
315
     * answer's index inside the part. Indices start at 0, so the first box of the first part
316
     * corresponds to 0_0, the third box of the second part is 1_2. If part *i* has *n* answer
317
     * boxes and a separate unit field, it will be named "i_n". For parts with a combined input
318
     * field for the answer and the unit (only possible for single answer parts), we use "i_".
319
     */
320
    public function get_expected_data(): array {
321
        $expected = [];
840✔
322
        foreach ($this->parts as $part) {
840✔
323
            $expected += $part->get_expected_data();
840✔
324
        }
325
        return $expected;
840✔
326
    }
327

328
    /**
329
     * Return the model answers as entered by the teacher. These answers should normally be sufficient
330
     * to get the maximum grade.
331
     *
332
     * @param qtype_formulas_part|null $part model answer for every answer / unit box of each part
333
     * @return array model answer for every answer / unit box of each part
334
     */
335
    public function get_correct_response(?qtype_formulas_part $part = null): array {
336
        // If the caller has requested one specific part, just return that response.
337
        if (isset($part)) {
2,730✔
338
            return $part->get_correct_response();
63✔
339
        }
340

341
        // Otherwise, fetch them all.
342
        $responses = [];
2,730✔
343
        foreach ($this->parts as $part) {
2,730✔
344
            $responses += $part->get_correct_response();
2,730✔
345
        }
346
        return $responses;
2,730✔
347
    }
348

349
    /**
350
     * Replace variables (if needed) and apply parent's format_text().
351
     *
352
     * @param string $text text to be output
353
     * @param int $format format (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN)
354
     * @param question_attempt $qa question attempt
355
     * @param string $component component ID, used for rewriting file area URLs
356
     * @param string $filearea file area
357
     * @param int $itemid the item id
358
     * @param bool $clean whether HTML needs to be cleaned (generally not needed for parts of the question)
359
     * @return string text formatted for output by format_text
360
     */
361
    public function format_text($text, $format, $qa, $component, $filearea, $itemid, $clean = false): string {
362
        // Doing a quick check whether there *might be* placeholders in the text. If this
363
        // is positive, we run it through the evaluator, even if it might not be needed.
364
        if (strpos($text, '{') !== false) {
1,995✔
365
            $text = $this->evaluator->substitute_variables_in_text($text);
1,554✔
366
        }
367
        return parent::format_text($text, $format, $qa, $component, $filearea, $itemid, $clean);
1,995✔
368
    }
369

370
    /**
371
     * Checks whether the users is allowed to be served a particular file. Overriding the parent method
372
     * is needed for the additional file areas (part text and feedback per part).
373
     *
374
     * @param question_attempt $qa question attempt being displayed
375
     * @param question_display_options $options options controlling display of the question
376
     * @param string $component component ID, used for rewriting file area URLs
377
     * @param string $filearea file area
378
     * @param array $args remaining bits of the file path
379
     * @param bool $forcedownload whether the user must be forced to download the file
380
     * @return bool whether the user can access this file
381
     */
382
    public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload): bool {
383
        // If $args is not properly specified, we won't grant access.
384
        if (!isset($args[0])) {
105✔
385
            return false;
21✔
386
        }
387
        // The first (remaining) element in the $args array is the item ID. This is either the question ID
388
        // or the part ID.
389
        $itemid = $args[0];
105✔
390

391
        // Files from the part's question text should be shown if the part ID matches one of our parts.
392
        if ($component === 'qtype_formulas' && $filearea === 'answersubqtext') {
105✔
393
            foreach ($this->parts as $part) {
21✔
394
                if ($part->id == $itemid) {
21✔
395
                    return true;
21✔
396
                }
397
            }
398
            // If we did not find a matching part, we don't serve the file.
399
            return false;
21✔
400
        }
401

402
        // If the question is not finished, we don't serve files belong to any feedback field.
403
        $ownfeedbackareas = ['answerfeedback', 'partcorrectfb', 'partpartiallycorrectfb', 'partincorrectfb'];
105✔
404
        if ($component === 'qtype_formulas' && in_array($filearea, $ownfeedbackareas)) {
105✔
405
            // If the $itemid does not belong to our parts, we can leave.
406
            $validpart = false;
63✔
407
            foreach ($this->parts as $part) {
63✔
408
                if ($part->id == $itemid) {
63✔
409
                    $validpart = true;
63✔
410
                    break;
63✔
411
                }
412
            }
413
            if (!$validpart) {
63✔
414
                return false;
63✔
415
            }
416

417
            // If the question is not finished, check if we have a gradable response. If we do,
418
            // calculate the grade and proceed. Otherwise, do not grant access to feedback files.
419
            $state = $qa->get_state();
63✔
420
            if (!$state->is_finished()) {
63✔
421
                $response = $qa->get_last_qt_data();
63✔
422
                if (!$this->is_gradable_response($response)) {
63✔
423
                    return false;
63✔
424
                }
425
                // Response is gradable, so try to grade and get the corresponding state.
426
                list($ignored, $state) = $this->grade_response($response);
21✔
427
            }
428

429
            // Files from the answerfeedback area belong to the part's general feedback. It is showed
430
            // for all answers, if feedback is enabled in the display options.
431
            if ($filearea === 'answerfeedback') {
63✔
432
                return $options->generalfeedback;
21✔
433
            }
434

435
            // Fetching the feedback class, i. e. 'correct' or 'partiallycorrect' or 'incorrect'.
436
            $feedbackclass = $state->get_feedback_class();
42✔
437

438
            // Only show files from specific feedback area if the given answer matches the kind of
439
            // feedback and if specific feedback is enabled in the display options.
440
            return ($options->feedback && $filearea === "part{$feedbackclass}fb");
42✔
441
        }
442

443
        $combinedfeedbackareas = ['correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'];
63✔
444
        if ($component === 'question' && in_array($filearea, $combinedfeedbackareas)) {
63✔
445
            return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args);
21✔
446
        }
447

448
        if ($component === 'question' && $filearea === 'hint') {
42✔
449
            return $this->check_hint_file_access($qa, $options, $args);
21✔
450
        }
451

452
        return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload);
21✔
453
    }
454

455
    /**
456
     * Used by many of the behaviours to determine whether the student has provided enough of an answer
457
     * for the question to be graded automatically, or whether it must be considered aborted.
458
     *
459
     * @param array $response responses, as returned by {@see \question_attempt_step::get_qt_data()}
460
     * @return bool whether this response can be graded
461
     */
462
    public function is_gradable_response(array $response): bool {
463
        if (array_key_exists('_seed', $response)) {
672✔
464
            return false;
420✔
465
        }
466
        // Iterate over all parts. If at least one part is gradable, we can leave early.
467
        foreach ($this->parts as $part) {
609✔
468
            if ($part->is_gradable_response($response)) {
609✔
469
                return true;
609✔
470
            }
471
        }
472

473
        // Still here? Then the question is not gradable.
474
        return false;
42✔
475
    }
476

477
    /**
478
     * Used by many of the behaviours, to work out whether the student's response to the question is
479
     * complete. That is, whether the question attempt should move to the COMPLETE or INCOMPLETE state.
480
     *
481
     * @param array $response responses, as returned by {@see \question_attempt_step::get_qt_data()}
482
     * @return bool whether this response is a complete answer to this question
483
     */
484
    public function is_complete_response(array $response): bool {
485
        // Iterate over all parts. If one part is not complete, we can return early.
486
        foreach ($this->parts as $part) {
1,680✔
487
            if (!$part->is_complete_response($response)) {
1,680✔
488
                return false;
546✔
489
            }
490
        }
491

492
        // Still here? Then all parts have been fully answered.
493
        return true;
1,344✔
494
    }
495

496
    /**
497
     * Used by many of the behaviours to determine whether the student's response has changed. This
498
     * is normally used to determine that a new set of responses can safely be discarded.
499
     *
500
     * @param array $prevresponse previously recorded responses, as returned by {@see \question_attempt_step::get_qt_data()}
501
     * @param array $newresponse new responses, in the same format
502
     * @return bool whether the two sets of responses are the same
503
     */
504
    public function is_same_response(array $prevresponse, array $newresponse) {
505
        // Check each part. If there is a difference in one part, we leave early.
506
        foreach ($this->parts as $part) {
357✔
507
            if (!$part->is_same_response($prevresponse, $newresponse)) {
357✔
508
                return false;
357✔
509
            }
510
        }
511

512
        // Still here? Then it's the same response.
513
        return true;
105✔
514
    }
515

516
    /**
517
     * Produce a plain text summary of a response to be used e. g. in reports.
518
     *
519
     * @param array $response student's response, as might be passed to {@see grade_response()}
520
     * @return string plain text summary
521
     */
522
    public function summarise_response(array $response) {
523
        $summary = [];
2,646✔
524

525
        // Summarise each part's answers.
526
        foreach ($this->parts as $part) {
2,646✔
527
            $summary[] = $part->summarise_response($response);
2,646✔
528
        }
529
        return implode('; ', $summary);
2,646✔
530
    }
531

532
    /**
533
     * Categorise the student's response according to the categories defined by get_possible_responses.
534
     *
535
     * @param array $response response, as might be passed to {@see grade_response()}
536
     * @return array subpartid => {@see \question_classified_response} objects;  empty array if no analysis is possible
537
     */
538
    public function classify_response(array $response) {
539
        // First, we normalize the student's answers.
540
        $response = $this->normalize_response($response);
483✔
541

542
        $classification = [];
483✔
543
        // Now, we do the classification for every part.
544
        foreach ($this->parts as $part) {
483✔
545
            // Unanswered parts can immediately be classified.
546
            // FIXME: change this if part allows empty fields
547
            if ($part->is_unanswered($response)) {
483✔
548
                $classification[$part->partindex] = question_classified_response::no_response();
126✔
549
                continue;
126✔
550
            }
551

552
            // If there is an answer, we check its correctness.
553
            list('answer' => $answergrade, 'unit' => $unitcorrect) = $part->grade($response);
399✔
554

555
            if ($part->postunit !== '') {
399✔
556
                // The unit can only be correct (1.0) or wrong (0.0).
557
                // The answer can be any float from 0.0 to 1.0 inclusive.
558
                if ($answergrade >= 0.999 && $unitcorrect) {
210✔
559
                    $classification[$part->partindex] = new question_classified_response(
42✔
560
                            'right', $part->summarise_response($response), 1);
42✔
561
                } else if ($unitcorrect) {
168✔
562
                    $classification[$part->partindex] = new question_classified_response(
42✔
563
                            'wrongvalue', $part->summarise_response($response), 0);
42✔
564
                } else if ($answergrade >= 0.999) {
126✔
565
                    $classification[$part->partindex] = new question_classified_response(
42✔
566
                            'wrongunit', $part->summarise_response($response), 1 - $part->unitpenalty);
42✔
567
                } else {
568
                    $classification[$part->partindex] = new question_classified_response(
138✔
569
                            'wrong', $part->summarise_response($response), 0);
138✔
570
                }
571
            } else {
572
                if ($answergrade >= .999) {
189✔
573
                    $classification[$part->partindex] = new question_classified_response(
126✔
574
                            'right', $part->summarise_response($response), $answergrade);
126✔
575
                } else {
576
                     $classification[$part->partindex] = new question_classified_response(
126✔
577
                            'wrong', $part->summarise_response($response), $answergrade);
126✔
578
                }
579
            }
580
        }
581
        return $classification;
483✔
582
    }
583

584
    /**
585
     * This method is called by the renderer when the question is in "invalid" state, i. e. if it
586
     * does not have a complete response (for immediate feedback or interactive mode) or if it has
587
     * an invalid part (in adaptive multipart mode).
588
     *
589
     * @param array $response student's response
590
     * @return string error message
591
     */
592
    public function get_validation_error(array $response): string {
593
        // If is_any_part_invalid() is true, that means no part is gradable, i. e. no fields
594
        // have been filled.
595
        if ($this->is_any_part_invalid($response)) {
189✔
596
            return get_string('allfieldsempty', 'qtype_formulas');
147✔
597
        }
598

599
        // If at least one part is gradable and yet the question is in "invalid" state, that means
600
        // that the behaviour expected all fields to be filled.
601
        return get_string('pleaseputananswer', 'qtype_formulas');
105✔
602
    }
603

604
    /**
605
     * Grade a response to the question, returning a fraction between get_min_fraction()
606
     * and 1.0, and the corresponding {@see \question_state} right, partial or wrong. This
607
     * method is used with immediate feedback, with adaptive mode and with interactive mode. It
608
     * is called after the studenet clicks "submit and finish" when deferred feedback is active.
609
     *
610
     * @param array $response responses, as returned by {@see \question_attempt_step::get_qt_data()}
611
     * @return array [0] => fraction (grade) and [1] => corresponding question state
612
     */
613
    public function grade_response(array $response) {
614
        $response = $this->normalize_response($response);
1,176✔
615

616
        $totalpossible = 0;
1,176✔
617
        $achievedmarks = 0;
1,176✔
618
        // Separately grade each part.
619
        foreach ($this->parts as $part) {
1,176✔
620
            // Count the total number of points for this part.
621
            $totalpossible += $part->answermark;
1,176✔
622

623
            $partsgrade = $part->grade($response);
1,176✔
624
            $fraction = $partsgrade['answer'];
1,176✔
625
            // If unit is wrong, make the necessary deduction.
626
            if ($partsgrade['unit'] === false) {
1,176✔
627
                $fraction = $fraction * (1 - $part->unitpenalty);
315✔
628
            }
629

630
            // Add the number of points achieved to the total.
631
            $achievedmarks += $part->answermark * $fraction;
1,176✔
632
        }
633

634
        // Finally, calculate the overall fraction of points received vs. possible points
635
        // and return the fraction together with the correct question state (i. e. correct,
636
        // partiall correct or wrong).
637
        $fraction = $achievedmarks / $totalpossible;
1,176✔
638
        return [$fraction, question_state::graded_state_for_fraction($fraction)];
1,176✔
639
    }
640

641
    /**
642
     * This method is called in multipart adaptive mode to grade the of the question
643
     * that can be graded. It returns the grade and penalty for each part, if (and only if)
644
     * the answer to that part has been changed since the last try. For parts that were
645
     * not retried, no grade or penalty should be returned.
646
     *
647
     * @param array $response current response (all fields)
648
     * @param array $lastgradedresponses array containing the (full) response given when each part registered
649
     *      an attempt for the last time; if there has been no try for a certain part, the corresponding key
650
     *      will be missing. Note that this is not the "history record" of all tries.
651
     * @param bool $finalsubmit true when the student clicks "submit all and finish"
652
     * @return array part name => qbehaviour_adaptivemultipart_part_result
653
     */
654
    public function grade_parts_that_can_be_graded(array $response, array $lastgradedresponses, $finalsubmit) {
655
        $partresults = [];
483✔
656

657
        foreach ($this->parts as $part) {
483✔
658
            // Check whether we already have an attempt for this part. If we don't, we create an
659
            // empty response.
660
            $lastresponse = [];
483✔
661
            if (array_key_exists($part->partindex, $lastgradedresponses)) {
483✔
662
                $lastresponse = $lastgradedresponses[$part->partindex];
315✔
663
            }
664

665
            // Check whether the response has been changed since the last attempt. If it has not,
666
            // we are done for this part.
667
            if ($part->is_same_response($lastresponse, $response)) {
483✔
668
                continue;
210✔
669
            }
670

671
            $partsgrade = $part->grade($response);
462✔
672
            $fraction = $partsgrade['answer'];
462✔
673
            // If unit is wrong, make the necessary deduction.
674
            if ($partsgrade['unit'] === false) {
462✔
675
                $fraction = $fraction * (1 - $part->unitpenalty);
63✔
676
            }
677

678
            $partresults[$part->partindex] = new qbehaviour_adaptivemultipart_part_result(
462✔
679
                $part->partindex, $fraction, $this->penalty
462✔
680
            );
462✔
681
        }
682

683
        return $partresults;
483✔
684
    }
685

686
    /**
687
     * Get a list of all the parts of the question and the weight they have within
688
     * the question.
689
     *
690
     * @return array part identifier => weight
691
     */
692
    public function get_parts_and_weights() {
693
        // First, we calculate the sum of all marks.
694
        $sum = 0;
378✔
695
        foreach ($this->parts as $part) {
378✔
696
            $sum += $part->answermark;
378✔
697
        }
698

699
        // Now that the total is known, we calculate each part's weight.
700
        $weights = [];
378✔
701
        foreach ($this->parts as $part) {
378✔
702
            $weights[$part->partindex] = $part->answermark / $sum;
378✔
703
        }
704

705
        return $weights;
378✔
706
    }
707

708
    /**
709
     * Check whether two responses for a given part (and only for that part) are identical.
710
     * This is used when working with multiple tries in order to avoid getting a penalty
711
     * deduction for an unchanged wrong answer that has already been counted before.
712
     *
713
     * @param string $id part indentifier
714
     * @param array $prevresponse previously recorded responses (for entire question)
715
     * @param array $newresponse new responses (for entire question)
716
     * @return bool
717
     */
718
    public function is_same_response_for_part($id, array $prevresponse, array $newresponse): bool {
719
        return $this->parts[$id]->is_same_response($prevresponse, $newresponse);
63✔
720
    }
721

722
    /**
723
     * This is called by adaptive multipart behaviour in order to determine whether the question
724
     * state should be moved to question_state::$invalid; many behaviours mainly or exclusively
725
     * use !is_complete_response() for that. We will return true if *no* part is gradable,
726
     * because in that case it does not make sense to proceed. If at least one part has been
727
     * answered (at least partially), we say that no part is invalid, because that allows the student
728
     * to get feedback for the answered parts.
729
     *
730
     * @param array $response student's response
731
     * @return bool returning false
732
     */
733
    public function is_any_part_invalid(array $response): bool {
734
        // Iterate over all parts. If at least one part is gradable, we can leave early.
735
        foreach ($this->parts as $part) {
420✔
736
            if ($part->is_gradable_response($response)) {
420✔
737
                return false;
420✔
738
            }
739
        }
740

741
        return true;
147✔
742
    }
743

744
    /**
745
     * Work out a final grade for this attempt, taking into account all the tries the student made.
746
     * This method is called in interactive mode when all tries are done or when the user hits
747
     * 'Submit and finish'.
748
     *
749
     * @param array $responses response for each try, each element (1 <= n <= $totaltries) is a response array
750
     * @param int $totaltries maximum number of tries allowed
751
     * @return float grade that should be awarded for this sequence of responses
752
     */
753
    public function compute_final_grade($responses, $totaltries): float {
754
        $obtainedgrade = 0;
189✔
755
        $maxgrade = 0;
189✔
756

757
        foreach ($this->parts as $part) {
189✔
758
            $maxgrade += $part->answermark;
189✔
759

760
            // We start with an empty last response.
761
            $lastresponse = [];
189✔
762
            $lastchange = 0;
189✔
763

764
            $partfraction = 0;
189✔
765

766
            foreach ($responses as $responseindex => $response) {
189✔
767
                // If the response has not changed, we have nothing to do.
768
                if ($part->is_same_response($lastresponse, $response)) {
189✔
769
                    continue;
63✔
770
                }
771

772
                $response = $this->normalize_response($response);
189✔
773

774
                // Otherwise, save this as the last response and store the index where
775
                // the response was changed for the last time.
776
                $lastresponse = $response;
189✔
777
                $lastchange = $responseindex;
189✔
778

779
                // Obtain the grade for the current response.
780
                $partgrade = $part->grade($response);
189✔
781

782
                $partfraction = $partgrade['answer'];
189✔
783
                // If unit is wrong, make the necessary deduction.
784
                if ($partgrade['unit'] === false) {
189✔
785
                    $partfraction = $partfraction * (1 - $part->unitpenalty);
84✔
786
                }
787
            }
788
            $obtainedgrade += $part->answermark * max(0,  $partfraction - $lastchange * $this->penalty);
189✔
789
        }
790

791
        return $obtainedgrade / $maxgrade;
189✔
792
    }
793

794
    /**
795
     * Set up an evaluator class for every part and have it evaluate the local variables.
796
     *
797
     * @return void
798
     */
799
    public function initialize_part_evaluators(): void {
800
        // For every part, we clone the question's evaluator in order to have the
801
        // same set of (instantiated) random and global variables.
802
        foreach ($this->parts as $part) {
3,171✔
803
            $part->evaluator = clone $this->evaluator;
3,171✔
804

805
            // Parse and evaluate the local variables, if there are any. We do not need to
806
            // retrieve or store the result, because the vars will be set inside the evaluator.
807
            if (!empty($part->vars1)) {
3,171✔
808
                $parser = new parser($part->vars1);
84✔
809
                $part->evaluator->evaluate($parser->get_statements());
84✔
810
            }
811

812
            // Parse, evaluate and store the model answers. They will be returned as tokens,
813
            // so we need to "unpack" them. We always store the model answers as an array; if
814
            // there is only one answer, we wrap the value into an array.
815
            $part->get_evaluated_answers();
3,171✔
816
        }
817
    }
818

819
    /**
820
     * Normalize student response for each part, i. e. split number and unit for combined answer
821
     * fields, trim answers and set missing answers to empty string to make sure all expected
822
     * response fields are set.
823
     *
824
     * @param array $response the student's response
825
     * @return array normalized response
826
     */
827
    public function normalize_response(array $response): array {
828
        $result = [];
1,449✔
829

830
        // Normalize the responses for each part.
831
        foreach ($this->parts as $part) {
1,449✔
832
            $result += $part->normalize_response($response);
1,449✔
833
        }
834

835
        // Set the 'normalized' key in order to mark the response as normalized; this is useful for
836
        // certain other functions, because it changes a combined field e.g. from 0_ to 0_0 and 0_1.
837
        $result['normalized'] = true;
1,449✔
838

839
        return $result;
1,449✔
840
    }
841
}
842

843
/**
844
 * Class to represent a question subpart, loaded from the question_answers table
845
 * in the database.
846
 *
847
 * @copyright  2012 Jean-Michel Védrine, 2023 Philipp Imhof
848
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
849
 */
850
class qtype_formulas_part {
851

852
    /** @var ?evaluator the part's evaluator class */
853
    public ?evaluator $evaluator = null;
854

855
    /** @var array store the evaluated model answer(s) */
856
    public array $evaluatedanswers = [];
857

858
    /** @var int the part's id */
859
    public int $id;
860

861
    /** @var int the parents question's id */
862
    public int $questionid;
863

864
    /** @var int the part's position among all parts of the question */
865
    public int $partindex;
866

867
    /** @var string the part's placeholder, e.g. #1 */
868
    public string $placeholder;
869

870
    /** @var float the maximum grade for this part */
871
    public float $answermark;
872

873
    /** @var int answer type (number, numerical, numerical formula, algebraic) */
874
    public int $answertype;
875

876
    /** @var int number of answer boxes (not including a possible unit box) for this part */
877
    public int $numbox;
878

879
    /** @var string definition of local variables */
880
    public string $vars1;
881

882
    /** @var string definition of grading variables */
883
    public string $vars2;
884

885
    /** @var string definition of the model answer(s) */
886
    public string $answer;
887

888
    /** @var int whether there are multiple possible answers */
889
    public int $answernotunique;
890

891
    /** @var int whether students can leave one or more fields empty */
892
    public int $emptyallowed;
893

894
    /** @var string definition of the grading criterion */
895
    public string $correctness;
896

897
    /** @var float deduction for a wrong unit */
898
    public float $unitpenalty;
899

900
    /** @var string unit */
901
    public string $postunit;
902

903
    /** @var int the set of basic unit conversion rules to be used */
904
    public int $ruleid;
905

906
    /** @var string additional conversion rules for other accepted base units */
907
    public string $otherrule;
908

909
    /** @var string the part's text */
910
    public string $subqtext;
911

912
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
913
    public int $subqtextformat;
914

915
    /** @var string general feedback for the part */
916
    public string $feedback;
917

918
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
919
    public int $feedbackformat;
920

921
    /** @var string part's feedback for any correct response */
922
    public string $partcorrectfb;
923

924
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
925
    public int $partcorrectfbformat;
926

927
    /** @var string part's feedback for any partially correct response */
928
    public string $partpartiallycorrectfb;
929

930
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
931
    public int $partpartiallycorrectfbformat;
932

933
    /** @var string part's feedback for any incorrect response */
934
    public string $partincorrectfb;
935

936
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
937
    public int $partincorrectfbformat;
938

939
    /**
940
     * Constructor.
941
     */
942
    public function __construct() {
943
    }
6,426✔
944

945
    /**
946
     * Whether or not a unit field is used in this part.
947
     *
948
     * @return bool
949
     */
950
    public function has_unit(): bool {
951
        return $this->postunit !== '';
3,486✔
952
    }
953

954
    /**
955
     * Whether or not the part has a combined input field for the number and the unit.
956
     *
957
     * @return bool
958
     */
959
    public function has_combined_unit_field(): bool {
960
        // In order to have a combined unit field, we must first assure that:
961
        // - there is a unit
962
        // - there is not more than one answer box
963
        // - the answer is not of the type algebraic formula.
964
        if (!$this->has_unit() || $this->numbox > 1 || $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
3,423✔
965
            return false;
2,772✔
966
        }
967

968
        // Furthermore, there must be either a {_0}{_u} without whitespace in the part's text
969
        // (meaning the user explicitly wants a combined unit field) or no answer box placeholders
970
        // at all, neither for the answer nor for the unit.
971
        $combinedrequested = strpos($this->subqtext, '{_0}{_u}') !== false;
1,134✔
972
        $noplaceholders = strpos($this->subqtext, '{_0}') === false && strpos($this->subqtext, '{_u}') === false;
1,134✔
973
        return $combinedrequested || $noplaceholders;
1,134✔
974
    }
975

976
    /**
977
     * Whether or not the part has a separate input field for the unit.
978
     *
979
     * @return bool
980
     */
981
    public function has_separate_unit_field(): bool {
982
        return $this->has_unit() && !$this->has_combined_unit_field();
2,898✔
983
    }
984

985
    /**
986
     * Check whether the previous response and the new response are the same for this part's fields.
987
     *
988
     * @param array $prevresponse previously recorded responses (for entire question)
989
     * @param array $newresponse new responses (for entire question)
990
     * @return bool
991
     */
992
    public function is_same_response(array $prevresponse, array $newresponse): bool {
993
        // Compare previous response and new response for every expected key.
994
        // If we have a difference at one point, we can return early.
995
        foreach (array_keys($this->get_expected_data()) as $key) {
735✔
996
            if (!question_utils::arrays_same_at_key_missing_is_blank($prevresponse, $newresponse, $key)) {
735✔
997
                return false;
714✔
998
            }
999
        }
1000

1001
        // Still here? That means they are all the same.
1002
        return true;
336✔
1003
    }
1004

1005
    /**
1006
     * Return the expected fields and data types for all answer boxes this part. This function
1007
     * is called by the main question's {@see get_expected_data()} method.
1008
     *
1009
     * @return array
1010
     */
1011
    public function get_expected_data(): array {
1012
        // The combined unit field is only possible for parts with one
1013
        // single answer box. If there are multiple input boxes, the
1014
        // number and unit box will not be merged.
1015
        if ($this->has_combined_unit_field()) {
2,877✔
1016
            return ["{$this->partindex}_" => PARAM_RAW];
420✔
1017
        }
1018

1019
        // First, we expect the answers, counting from 0 to numbox - 1.
1020
        $expected = [];
2,541✔
1021
        for ($i = 0; $i < $this->numbox; $i++) {
2,541✔
1022
            $expected["{$this->partindex}_$i"] = PARAM_RAW;
2,541✔
1023
        }
1024

1025
        // If there is a separate unit field, we add it to the list.
1026
        if ($this->has_separate_unit_field()) {
2,541✔
1027
            $expected["{$this->partindex}_{$this->numbox}"] = PARAM_RAW;
378✔
1028
        }
1029
        return $expected;
2,541✔
1030
    }
1031

1032
    /**
1033
     * Parse a string (i. e. the part's text) looking for answer box placeholders.
1034
     * Answer box placeholders have one of the following forms:
1035
     * - {_u} for the unit box
1036
     * - {_n} for an answer box, n must be an integer
1037
     * - {_n:str} or {_n:str:MC} for radio buttons, str must be a variable name
1038
     * - {_n:str:MCS} for *shuffled* radio buttons, str must be a variable name
1039
     * - {_n:str:MCE} for a drop down field, MCE must be verbatim
1040
     * - {_n:str:MCES} for a *shuffled* drop down field, MCE must be verbatim
1041
     * Note: {_0:MCE} is valid and will lead to radio boxes based on the variable MCE.
1042
     * Every answer box in the array will itself be an associative array with the
1043
     * keys 'placeholder' (the entire placeholder), 'options' (the name of the variable containing
1044
     * the options for the radio list or the dropdown) and 'dropdown' (true or false).
1045
     * The method is declared static in order to allow its usage during form validation when
1046
     * there is no actual question object.
1047
     * TODO: allow {_n|50px} or {_n|10} to control size of the input field
1048
     *
1049
     * @param string $text string to be parsed
1050
     * @return array
1051
     */
1052
    public static function scan_for_answer_boxes(string $text): array {
1053
        // Match the text and store the matches.
1054
        preg_match_all('/\{(_u|_\d+)(:(_[A-Za-z]|[A-Za-z]\w*)(:(MC|MCE|MCS|MCES))?)?((\|[\w .=#]*)*)\}/', $text, $matches);
6,153✔
1055

1056
        $boxes = [];
6,153✔
1057

1058
        // The array $matches[1] contains the matches of the first capturing group, i. e. _1 or _u.
1059
        foreach ($matches[1] as $i => $match) {
6,153✔
1060
            // Duplicates are not allowed.
1061
            if (array_key_exists($match, $boxes)) {
3,525✔
1062
                throw new Exception(get_string('error_answerbox_duplicate', 'qtype_formulas', $match));
252✔
1063
            }
1064
            // The array $matches[0] contains the entire pattern, e.g. {_1:var:MCE} or simply {_3}. This
1065
            // text is later needed to replace the placeholder by the input element.
1066
            // With $matches[3], we can access the name of the variable containing the options for the radio
1067
            // boxes or the drop down list.
1068
            // Finally, the array $matches[4] will contain ':MC', ':MCE', ':MCES' or ':MCS' in case this has been
1069
            // specified. Otherwise, there will be an empty string.
1070
            $boxes[$match] = [
3,525✔
1071
                'placeholder' => $matches[0][$i],
3,525✔
1072
                'options' => $matches[3][$i],
3,525✔
1073
                'dropdown' => (substr($matches[4][$i], 0, 4) === ':MCE'),
3,525✔
1074
                'shuffle' => (substr($matches[4][$i], -1) === 'S'),
3,525✔
1075
                'format' => self::parse_box_formatting_options(substr($matches[6][$i], 1)),
3,525✔
1076
            ];
3,525✔
1077
        }
1078
        return $boxes;
5,901✔
1079
    }
1080

1081
    /**
1082
     * Parse a string of format options, as used in the definition of a text box placeholder,
1083
     * e. g. |w=10px|bgcol=yellow.
1084
     *
1085
     * @param string $settings format settings
1086
     * @return array associative array 'optionname' => 'value', e. g. 'w' => '50px'
1087
     */
1088
    protected static function parse_box_formatting_options(string $settings): array {
1089
        $options = explode('|', $settings);
3,525✔
1090

1091
        $result = [];
3,525✔
1092
        foreach ($options as $option) {
3,525✔
1093
            if (strstr($option, '=') === false) {
3,525✔
1094
                continue;
2,874✔
1095
            }
1096
            $namevalue = explode('=', $option);
777✔
1097
            $result[$namevalue[0]] = $namevalue[1];
777✔
1098
        }
1099

1100
        return $result;
3,525✔
1101
    }
1102

1103
    /**
1104
     * Produce a plain text summary of a response for the part.
1105
     *
1106
     * @param array $response a response, as might be passed to {@see grade_response()}.
1107
     * @return string a plain text summary of that response, that could be used in reports.
1108
     */
1109
    public function summarise_response(array $response) {
1110
        $summary = [];
2,646✔
1111

1112
        $isnormalized = array_key_exists('normalized', $response);
2,646✔
1113

1114
        // If the part has a combined unit field, we want to have the number and the unit
1115
        // to appear together in the summary.
1116
        if ($this->has_combined_unit_field()) {
2,646✔
1117
            // If the answer is normalized, the combined field has already been split, so we
1118
            // recombine both parts.
1119
            if ($isnormalized) {
840✔
1120
                return trim($response["{$this->partindex}_0"] . " " . $response["{$this->partindex}_1"]);
84✔
1121
            }
1122

1123
            // Otherwise, we check whether the key 0_ or similar is present in the response. If it is,
1124
            // we return that value.
1125
            if (isset($response["{$this->partindex}_"])) {
840✔
1126
                return $response["{$this->partindex}_"];
840✔
1127
            }
1128
        }
1129

1130
        // Iterate over all expected answer fields and if there is a corresponding
1131
        // answer in $response, add its value to the summary array.
1132
        foreach (array_keys($this->get_expected_data()) as $key) {
2,247✔
1133
            if (array_key_exists($key, $response)) {
2,247✔
1134
                $summary[] = $response[$key];
2,226✔
1135
            }
1136
        }
1137

1138
        // Transform the array to a comma-separated list for a nice summary.
1139
        return implode(', ', $summary);
2,247✔
1140
    }
1141

1142
    /**
1143
     * Normalize student response for current part, i. e. split number and unit for combined answer
1144
     * fields, trim answers and set missing answers to empty string to make sure all expected
1145
     * response fields are set.
1146
     *
1147
     * @param array $response student's full response
1148
     * @return array normalized response for this part only
1149
     */
1150
    public function normalize_response(array $response): array {
1151
        $result = [];
2,898✔
1152

1153
        // There might be a combined field for number and unit which would be called i_.
1154
        // We check this first. A combined field is only possible, if there is not more
1155
        // than one answer, so we can safely use i_0 for the number and i_1 for the unit.
1156
        $name = "{$this->partindex}_";
2,898✔
1157
        if (isset($response[$name])) {
2,898✔
1158
            $combined = trim($response[$name]);
525✔
1159

1160
            // We try to parse the student's response in order to find the position where
1161
            // the unit presumably starts. If parsing fails (e. g. because there is a syntax
1162
            // error), we consider the entire response to be the "number". It will later be graded
1163
            // wrong anyway.
1164
            try {
1165
                $parser = new answer_parser($combined);
525✔
1166
                $splitindex = $parser->find_start_of_units();
525✔
1167
            } catch (Throwable $t) {
21✔
1168
                // TODO: convert to non-capturing catch.
1169
                $splitindex = PHP_INT_MAX;
21✔
1170
            }
1171

1172
            $number = trim(substr($combined, 0, $splitindex));
525✔
1173
            $unit = trim(substr($combined, $splitindex));
525✔
1174

1175
            $result["{$name}0"] = $number;
525✔
1176
            $result["{$name}1"] = $unit;
525✔
1177
            return $result;
525✔
1178
        }
1179

1180
        // Otherwise, we iterate from 0 to numbox, inclusive (if there is a unit field) or exclusive.
1181
        $count = $this->numbox;
2,877✔
1182
        if ($this->has_unit()) {
2,877✔
1183
            $count++;
1,092✔
1184
        }
1185
        for ($i = 0; $i < $count; $i++) {
2,877✔
1186
            $name = "{$this->partindex}_$i";
2,877✔
1187

1188
            // If there is an answer, we strip white space from the start and end.
1189
            // Missing answers should be empty strings.
1190
            if (isset($response[$name])) {
2,877✔
1191
                $result[$name] = trim($response[$name]);
1,638✔
1192
            } else {
1193
                $result[$name] = '';
2,016✔
1194
            }
1195

1196
            // Restrict the answer's length to 128 characters. There is no real need
1197
            // for this, but it was done in the first versions, so we'll keep it for
1198
            // backwards compatibility.
1199
            if (strlen($result[$name]) > 128) {
2,877✔
1200
                $result[$name] = substr($result[$name], 0, 128);
21✔
1201
            }
1202
        }
1203

1204
        return $result;
2,877✔
1205
    }
1206

1207
    /**
1208
     * Determines whether the student has entered enough in order for this part to
1209
     * be graded. We consider a part gradable, if it is not unanswered, i. e. if at
1210
     * least some field has been filled.
1211
     *
1212
     * @param array $response
1213
     * @return bool
1214
     */
1215
    public function is_gradable_response(array $response): bool {
1216
        // If the part allows empty fields, we do not have to check anything; the response would be
1217
        // gradable even if all fields were empty.
1218
        if ($this->emptyallowed) {
651✔
1219
            return true;
21✔
1220
        }
1221
        return !$this->is_unanswered($response);
630✔
1222
    }
1223

1224
    /**
1225
     * Determines whether the student has provided a complete answer to this part,
1226
     * i. e. if all fields have been filled. This method can be called before the
1227
     * response is normalized, so we cannot be sure all array keys exist as we would
1228
     * expect them.
1229
     *
1230
     * @param array $response
1231
     * @return bool
1232
     */
1233
    public function is_complete_response(array $response): bool {
1234
        // If the part allows empty fields, we do not have to check anything; the response can be
1235
        // considered complete even if all fields are empty.
1236
        if ($this->emptyallowed) {
1,680✔
1237
            return true;
21✔
1238
        }
1239
        // First, we check if there is a combined unit field. In that case, there will
1240
        // be only one field to verify.
1241
        if ($this->has_combined_unit_field()) {
1,659✔
1242
            return !empty($response["{$this->partindex}_"]) && strlen($response["{$this->partindex}_"]) > 0;
462✔
1243
        }
1244

1245
        // If we are still here, we do now check all "normal" fields. If one is empty,
1246
        // we can return early.
1247
        for ($i = 0; $i < $this->numbox; $i++) {
1,239✔
1248
            if (!isset($response["{$this->partindex}_{$i}"]) || strlen($response["{$this->partindex}_{$i}"]) == 0) {
1,239✔
1249
                return false;
441✔
1250
            }
1251
        }
1252

1253
        // Finally, we check whether there is a separate unit field and, if necessary,
1254
        // make sure it is not empty.
1255
        if ($this->has_separate_unit_field()) {
1,071✔
1256
            return !empty($response["{$this->partindex}_{$this->numbox}"])
189✔
1257
                && strlen($response["{$this->partindex}_{$this->numbox}"]) > 0;
189✔
1258
        }
1259

1260
        // Still here? That means no expected field was missing and no fields were empty.
1261
        return true;
924✔
1262
    }
1263

1264
    /**
1265
     * Determines whether the part (as a whole) is unanswered.
1266
     *
1267
     * @param array $response
1268
     * @return bool
1269
     */
1270
    public function is_unanswered(array $response): bool {
1271
        if (array_key_exists('_seed', $response)) {
1,113✔
1272
            return true;
126✔
1273
        }
1274
        if (!array_key_exists('normalized', $response)) {
1,113✔
1275
            $response = $this->normalize_response($response);
630✔
1276
        }
1277

1278
        // Check all answer boxes (including a possible unit) of this part. If at least one is not empty,
1279
        // the part has been answered. If there is a unit field, we will check this in the same way, even
1280
        // if it should not actually be numeric. We don't need to care about that, because a wrong answer
1281
        // is still an answer. Note that $response will contain *all* answers for *all* parts.
1282
        $count = $this->numbox;
1,113✔
1283
        if ($this->has_unit()) {
1,113✔
1284
            $count++;
567✔
1285
        }
1286
        for ($i = 0; $i < $count; $i++) {
1,113✔
1287
            // If the answer field is not empty or it is equivalent to zero, we consider
1288
            // the part as answered and leave early.
1289
            $tocheck = $response["{$this->partindex}_{$i}"];
1,113✔
1290
            if (!empty($tocheck) || is_numeric($tocheck)) {
1,113✔
1291
                return false;
1,029✔
1292
            }
1293
        }
1294

1295
        // Still here? Then no fields were filled.
1296
        return true;
336✔
1297
    }
1298

1299
    /**
1300
     * Return the part's evaluated answers. The teacher has probably entered them using the
1301
     * various (random, global and/or local) variables. This function calculates the numerical
1302
     * value of all answers. If the answer type is 'algebraic formula', the answers are not
1303
     * evaluated (this would not make sense), but the non-algebraic variables will be replaced
1304
     * by their respective values, e.g. "a*x^2" could become "2*x^2" if the variable a is defined
1305
     * to be 2.
1306
     *
1307
     * @return array
1308
     */
1309
    public function get_evaluated_answers(): array {
1310
        // If we already know the evaluated answers for this part, we can simply return them.
1311
        if (!empty($this->evaluatedanswers)) {
5,775✔
1312
            return $this->evaluatedanswers;
5,628✔
1313
        }
1314

1315
        // Still here? Then let's evaluate the answers.
1316
        $result = [];
5,775✔
1317

1318
        // Check whether the part uses the algebraic answer type.
1319
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
5,775✔
1320

1321
        $parser = new parser($this->answer, $this->evaluator->export_variable_list());
5,775✔
1322
        $result = $this->evaluator->evaluate($parser->get_statements())[0];
5,775✔
1323

1324
        // The $result will now be a token with its value being either a literal (string, number)
1325
        // or an array of literal tokens. If we have one single answer, we wrap it into an array
1326
        // before continuing. Otherwise we convert the array of tokens into an array of literals.
1327
        if ($result->type & token::ANY_LITERAL) {
5,775✔
1328
            $this->evaluatedanswers = [$result->value];
5,385✔
1329
        } else {
1330
            $this->evaluatedanswers = array_map(function ($element) {
453✔
1331
                return $element->value;
453✔
1332
            }, $result->value);
453✔
1333
        }
1334

1335
        // If the answer type is algebraic, substitute all non-algebraic variables by
1336
        // their numerical value.
1337
        if ($isalgebraic) {
5,775✔
1338
            foreach ($this->evaluatedanswers as &$answer) {
447✔
1339
                // If the answer is $EMPTY, there is nothing to do.
1340
                if ($answer === '$EMPTY') {
447✔
NEW
UNCOV
1341
                    continue;
×
1342
                }
1343
                $answer = $this->evaluator->substitute_variables_in_algebraic_formula($answer);
447✔
1344
            }
1345
            // In case we later write to $answer, this would alter the last entry of the $modelanswers
1346
            // array, so we'd better remove the reference to make sure this won't happend.
1347
            unset($answer);
447✔
1348
        }
1349

1350
        return $this->evaluatedanswers;
5,775✔
1351
    }
1352

1353
    /**
1354
     * This function takes an array of algebraic formulas and wraps them in quotes. This is
1355
     * needed e.g. before we feed them to the parser for further processing.
1356
     *
1357
     * @param array $formulas the formulas to be wrapped in quotes
1358
     * @return array array containing wrapped formulas
1359
     */
1360
    private static function wrap_algebraic_formulas_in_quotes(array $formulas): array {
1361
        foreach ($formulas as &$formula) {
594✔
1362
            // We do not have to wrap the $EMPTY token in quotes.
1363
            if ($formula === '$EMPTY') {
594✔
NEW
UNCOV
1364
                continue;
×
1365
            }
1366

1367
            // If the formula is aready wrapped in quotes, we throw an Exception, because that
1368
            // should not happen. It will happen, if the student puts quotes around their response, but
1369
            // we want that to be graded wrong. The exception will be caught and dealt with upstream,
1370
            // so we do not need to be more precise.
1371
            if (preg_match('/^\"[^\"]*\"$/', $formula)) {
594✔
1372
                throw new Exception();
84✔
1373
            }
1374

1375
            $formula = '"' . $formula . '"';
531✔
1376
        }
1377
        // In case we later write to $formula, this would alter the last entry of the $formulas
1378
        // array, so we'd better remove the reference to make sure this won't happen.
1379
        unset($formula);
531✔
1380

1381
        return $formulas;
531✔
1382
    }
1383

1384
    /**
1385
     * Check whether algebraic formulas contain a PREFIX operator.
1386
     *
1387
     * @param array $formulas the formulas to check
1388
     * @return bool
1389
     */
1390
    private static function contains_prefix_operator(array $formulas): bool {
1391
        foreach ($formulas as $formula) {
300✔
1392
            $lexer = new lexer($formula);
300✔
1393

1394
            foreach ($lexer->get_tokens() as $token) {
300✔
1395
                if ($token->type === token::PREFIX) {
300✔
1396
                    return true;
42✔
1397
                }
1398
            }
1399
        }
1400

1401
        return false;
279✔
1402
    }
1403

1404
    /**
1405
     * Add several special variables to the question part's evaluator, namely
1406
     * _a for the model answers (array)
1407
     * _r for the student's response (array)
1408
     * _d for the differences between _a and _r (array)
1409
     * _0, _1 and so on for the student's individual answers
1410
     * _err for the absolute error
1411
     * _relerr for the relative error, if applicable
1412
     *
1413
     * @param array $studentanswers the student's response
1414
     * @param float $conversionfactor unit conversion factor, if needed (1 otherwise)
1415
     * @param bool $formodelanswer whether we are doing this to test model answers, i. e. PREFIX operator is allowed
1416
     * @return void
1417
     */
1418
    public function add_special_variables(array $studentanswers, float $conversionfactor, bool $formodelanswer = false): void {
1419
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
4,053✔
1420

1421
        // First, we set _a to the array of model answers. We can use the
1422
        // evaluated answers. The function get_evaluated_answers() uses a cache.
1423
        // Answers of type alebraic formula must be wrapped in quotes.
1424
        $modelanswers = $this->get_evaluated_answers();
4,053✔
1425
        if ($isalgebraic) {
4,053✔
1426
            $modelanswers = self::wrap_algebraic_formulas_in_quotes($modelanswers);
300✔
1427
        }
1428
        $command = '_a = [' . implode(',', $modelanswers ). '];';
4,053✔
1429

1430
        // The variable _r will contain the student's answers, scaled according to the unit,
1431
        // but not containing the unit. Also, the variables _0, _1, ... will contain the
1432
        // individual answers.
1433
        if ($isalgebraic) {
4,053✔
1434
            // Students are not allowed to use the PREFIX operator. If they do, we drop out
1435
            // here. Throwing an exception will make sure the grading function awards zero points.
1436
            // Also, we disallow usage of the PREFIX in an algebraic formula's model answer, because
1437
            // that would lead to bad feedback (showing the student a "correct" answer that they cannot
1438
            // type in). In this case, we use a different error message.
1439
            if (self::contains_prefix_operator($studentanswers)) {
300✔
1440
                if ($formodelanswer) {
42✔
1441
                    throw new Exception(get_string('error_model_answer_prefix', 'qtype_formulas'));
21✔
1442
                }
1443
                throw new Exception(get_string('error_prefix', 'qtype_formulas'));
21✔
1444
            }
1445
            $studentanswers = self::wrap_algebraic_formulas_in_quotes($studentanswers);
279✔
1446
        }
1447
        $ssqstudentanswer = 0;
4,032✔
1448
        foreach ($studentanswers as $i => &$studentanswer) {
4,032✔
1449
            // We only do the calculation if the answer type is not algebraic. For algebraic
1450
            // answers, we don't do anything, because quotes have already been added.
1451
            if (!$isalgebraic && $studentanswer !== '$EMPTY') {
4,032✔
1452
                $studentanswer = $conversionfactor * $studentanswer;
3,774✔
1453
                $ssqstudentanswer += $studentanswer ** 2;
3,774✔
1454
            }
1455
            $command .= "_{$i} = {$studentanswer};";
4,032✔
1456
        }
1457
        unset($studentanswer);
4,032✔
1458
        $command .= '_r = [' . implode(',', $studentanswers) . '];';
4,032✔
1459

1460
        // The variable _d will contain the absolute differences between the model answer
1461
        // and the student's response. Using the parser's diff() function will make sure
1462
        // that algebraic answers are correctly evaluated.
1463
        // Note: We *must* send the model answer first, because the function has a special check for the
1464
        // EMPTY token.
1465
        $command .= '_d = diff(_a, _r);';
4,032✔
1466
        $command .= "_err = sqrt(sum(map('*', _d, _d)));";
4,032✔
1467

1468
        // Finally, calculate the relative error, unless the question uses an algebraic answer.
1469
        if (!$isalgebraic) {
4,032✔
1470
            // We calculate the sum of squares of all model answers.
1471
            $ssqmodelanswer = 0;
3,774✔
1472
            foreach ($this->get_evaluated_answers() as $answer) {
3,774✔
1473
                if ($answer === '$EMPTY') {
3,774✔
1474
                    continue;
21✔
1475
                }
1476
                $ssqmodelanswer += $answer ** 2;
3,774✔
1477
            }
1478
            // If the sum of squares is 0 (i.e. all answers are 0), then either the student
1479
            // answers are all 0 as well, in which case we set the relative error to 0. Or
1480
            // they are not, in which case we set the relative error to the greatest possible value.
1481
            // Otherwise, the relative error is simply the absolute error divided by the root
1482
            // of the sum of squares.
1483
            if ($ssqmodelanswer == 0) {
3,774✔
1484
                $command .= '_relerr = ' . ($ssqstudentanswer == 0 ? 0 : PHP_FLOAT_MAX);
111✔
1485
            } else {
1486
                $command .= "_relerr = _err / sqrt({$ssqmodelanswer})";
3,663✔
1487
            }
1488
        }
1489
        $parser = new parser($command, $this->evaluator->export_variable_list());
4,032✔
1490
        $this->evaluator->evaluate($parser->get_statements(), true);
4,032✔
1491
    }
1492

1493
    /**
1494
     * Grade the part and return its grade.
1495
     *
1496
     * @param array $response current response
1497
     * @param bool $finalsubmit true when the student clicks "submit all and finish"
1498
     * @return array 'answer' => grade (0...1) for this response, 'unit' => whether unit is correct (bool)
1499
     */
1500
    public function grade(array $response, bool $finalsubmit = false): array {
1501
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
2,751✔
1502

1503
        // Normalize the student's response for this part, removing answers from other parts.
1504
        $response = $this->normalize_response($response);
2,751✔
1505

1506
        // Store the unit as entered by the student and get rid of this part of the
1507
        // array, leaving only the inputs from the number fields.
1508
        $studentsunit = '';
2,751✔
1509
        if ($this->has_unit()) {
2,751✔
1510
            $studentsunit = trim($response["{$this->partindex}_{$this->numbox}"]);
1,050✔
1511
            unset($response["{$this->partindex}_{$this->numbox}"]);
1,050✔
1512
        }
1513

1514
        // For now, let's assume the unit is correct.
1515
        $unitcorrect = true;
2,751✔
1516

1517
        // Check whether the student's unit is compatible, i. e. whether it can be converted to
1518
        // the unit set by the teacher. If this is the case, we calculate the conversion factor.
1519
        // Otherwise, we set $unitcorrect to false and let the conversion factor be 1, so the
1520
        // result will not be "scaled".
1521
        $conversionfactor = $this->is_compatible_unit($studentsunit);
2,751✔
1522
        if ($conversionfactor === false) {
2,751✔
1523
            $conversionfactor = 1;
945✔
1524
            $unitcorrect = false;
945✔
1525
        }
1526

1527
        // The response array does not contain the unit anymore. If we are dealing with algebraic
1528
        // formulas, we must wrap the answers in quotes before we move on. Also, we reset the conversion
1529
        // factor, because it is not needed for algebraic answers.
1530
        if ($isalgebraic) {
2,751✔
1531
            try {
1532
                $response = self::wrap_algebraic_formulas_in_quotes($response);
189✔
1533
            } catch (Throwable $t) {
21✔
1534
                // TODO: convert to non-capturing catch.
1535
                return ['answer' => 0, 'unit' => $unitcorrect];
21✔
1536
            }
1537
            $conversionfactor = 1;
189✔
1538
        }
1539

1540
        // Now we iterate over all student answers, feed them to the parser and evaluate them in order
1541
        // to build an array containing the evaluated response.
1542
        $evaluatedresponse = [];
2,751✔
1543
        foreach ($response as $answer) {
2,751✔
1544
            try {
1545
                // Using the known variables upon initialisation allows the teacher to "block"
1546
                // certain built-in functions for the student by overwriting them, e. g. by
1547
                // defining "sin = 1" in the global variables.
1548
                $parser = new answer_parser($answer, $this->evaluator->export_variable_list());
2,751✔
1549

1550
                // Check whether the answer is valid for the given answer type. If it is not,
1551
                // we just throw an exception to make use of the catch block. Note that if the
1552
                // student's answer was empty, it will fail in this check.
1553
                if (!$parser->is_acceptable_for_answertype($this->answertype, $this->emptyallowed)) {
2,751✔
1554
                    throw new Exception();
2,037✔
1555
                }
1556

1557
                // Make sure the stack is empty, as there might be left-overs from a previous
1558
                // failed evaluation, e.g. caused by an invalid answer.
1559
                $this->evaluator->clear_stack();
1,449✔
1560

1561
                // Evaluate. If the answer was empty (an empty string or the '$EMPTY'), the parser
1562
                // will create an appropriate evaluable statement or return an empty array. The evaluator,
1563
                // on the other hand, will know how to deal with the "false" return value from reset()
1564
                // and return the $EMPTY token.
1565
                $statements = $parser->get_statements();
1,449✔
1566
                $evaluatedresponse[] = token::unpack($this->evaluator->evaluate(reset($statements)));
1,449✔
1567
            } catch (Throwable $t) {
2,037✔
1568
                // TODO: convert to non-capturing catch
1569
                // If parsing, validity check or evaluation fails, we consider the answer as wrong.
1570
                // The unit might be correct, but that won't matter.
1571
                return ['answer' => 0, 'unit' => $unitcorrect];
2,037✔
1572
            }
1573
        }
1574

1575
        // Add correctness variables using the evaluated response.
1576
        try {
1577
            $this->add_special_variables($evaluatedresponse, $conversionfactor);
1,449✔
1578
        } catch (Exception $e) {
63✔
1579
            // TODO: convert to non-capturing catch
1580
            // If the special variables cannot be evaluated, the answer will be considered as
1581
            // wrong. Partial credit may be given for the unit. We do not carry on, because
1582
            // evaluation of the grading criterion (and possibly grading variables) generally
1583
            // depends on these special variables.
1584
            return ['answer' => 0, 'unit' => $unitcorrect];
63✔
1585
        }
1586

1587
        // Fetch and evaluate grading variables.
1588
        $gradingparser = new parser($this->vars2);
1,449✔
1589
        try {
1590
            $this->evaluator->evaluate($gradingparser->get_statements());
1,449✔
1591
        } catch (Exception $e) {
21✔
1592
            // TODO: convert to non-capturing catch
1593
            // If grading variables cannot be evaluated, the answer will be considered as
1594
            // wrong. Partial credit may be given for the unit. Thus, we do not need to
1595
            // carry on.
1596
            return ['answer' => 0, 'unit' => $unitcorrect];
21✔
1597
        }
1598

1599
        // Fetch and evaluate the grading criterion. If evaluation is not possible,
1600
        // set grade to 0.
1601
        $correctnessparser = new parser($this->correctness);
1,449✔
1602
        try {
1603
            $evaluatedgrading = $this->evaluator->evaluate($correctnessparser->get_statements())[0];
1,449✔
1604
            $evaluatedgrading = $evaluatedgrading->value;
1,449✔
1605
        } catch (Exception $e) {
21✔
1606
            // TODO: convert to non-capturing catch.
1607
            $evaluatedgrading = 0;
21✔
1608
        }
1609

1610
        // Restrict the grade to the closed interval [0,1].
1611
        $evaluatedgrading = min($evaluatedgrading, 1);
1,449✔
1612
        $evaluatedgrading = max($evaluatedgrading, 0);
1,449✔
1613

1614
        return ['answer' => $evaluatedgrading, 'unit' => $unitcorrect];
1,449✔
1615
    }
1616

1617
    /**
1618
     * Check whether the unit in the student's answer can be converted into the expected unit.
1619
     * TODO: refactor this once the unit system has been rewritten
1620
     *
1621
     * @param string $studentsunit unit provided by the student
1622
     * @return float|bool false if not compatible, conversion factor if compatible
1623
     */
1624
    private function is_compatible_unit(string $studentsunit) {
1625
        $checkunit = new answer_unit_conversion();
2,751✔
1626
        $conversionrules = new unit_conversion_rules();
2,751✔
1627
        $entry = $conversionrules->entry($this->ruleid);
2,751✔
1628
        $checkunit->assign_default_rules($this->ruleid, $entry[1]);
2,751✔
1629
        $checkunit->assign_additional_rules($this->otherrule);
2,751✔
1630

1631
        $checked = $checkunit->check_convertibility($studentsunit, $this->postunit);
2,751✔
1632
        if ($checked->convertible) {
2,751✔
1633
            return $checked->cfactor;
2,457✔
1634
        }
1635

1636
        return false;
945✔
1637
    }
1638

1639
    /**
1640
     * Return an array containing the correct answers for this question part. If $forfeedback
1641
     * is set to true, multiple choice answers are translated from their list index to their
1642
     * value (e.g. the text) to provide feedback to the student. Also, quotes are stripped
1643
     * from algebraic formulas. Otherwise, the function returns the values as they are needed
1644
     * to obtain full mark at this question.
1645
     *
1646
     * @param bool $forfeedback whether we request correct answers for student feedback
1647
     * @return array list of correct answers
1648
     */
1649
    public function get_correct_response(bool $forfeedback = false): array {
1650
        // Fetch the evaluated answers.
1651
        $answers = $this->get_evaluated_answers();
2,730✔
1652

1653
        // Numeric answers should be localized, if that functionality is enabled.
1654
        // Empty answers should be just the empty string; a more user-friendly
1655
        // output will be created in the renderer.
1656
        foreach ($answers as &$answer) {
2,730✔
1657
            if ($answer === '$EMPTY') {
2,730✔
1658
                $answer = '';
21✔
1659
            } else if (is_numeric($answer)) {
2,730✔
1660
                $answer = qtype_formulas::format_float($answer);
2,688✔
1661
            }
1662
        }
1663
        // Make sure we do not accidentally write to $answer later.
1664
        unset($answer);
2,730✔
1665

1666
        // If we have a combined unit field, we return both the model answer plus the unit
1667
        // in "i_". Combined fields are only possible for parts with one signle answer.
1668
        if ($this->has_combined_unit_field()) {
2,730✔
1669
            return ["{$this->partindex}_" => trim($answers[0] . ' ' . $this->postunit)];
840✔
1670
        }
1671

1672
        // As algebraic formulas are not numbers, we must replace the decimal point separately.
1673
        // Also, if the answer is requested for feedback, we must strip the quotes.
1674
        // Strip quotes around algebraic formulas, if the answers are used for feedback.
1675
        if ($this->answertype === qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
2,310✔
1676
            if (get_config('qtype_formulas', 'allowdecimalcomma')) {
210✔
1677
                $answers = str_replace('.', get_string('decsep', 'langconfig'), $answers);
21✔
1678
            }
1679

1680
            if ($forfeedback) {
210✔
1681
                $answers = str_replace('"', '', $answers);
21✔
1682
            }
1683
        }
1684

1685
        // Otherwise, we build an array with all answers, according to our naming scheme.
1686
        $res = [];
2,310✔
1687
        for ($i = 0; $i < $this->numbox; $i++) {
2,310✔
1688
            $res["{$this->partindex}_{$i}"] = $answers[$i];
2,310✔
1689
        }
1690

1691
        // Finally, if we have a separate unit field, we add this as well.
1692
        if ($this->has_separate_unit_field()) {
2,310✔
1693
            $res["{$this->partindex}_{$this->numbox}"] = $this->postunit;
336✔
1694
        }
1695

1696
        if ($forfeedback) {
2,310✔
1697
            $res = $this->translate_mc_answers_for_feedback($res);
336✔
1698
        }
1699

1700
        return $res;
2,310✔
1701
    }
1702

1703
    /**
1704
     * When using multichoice options in a question (e.g. radio buttons or a dropdown list),
1705
     * the answer will be stored as a number, e.g. 1 for the *second* option. However, when
1706
     * giving feedback to the student, we want to show the actual *value* of the option and not
1707
     * its number. This function makes sure the stored answer is translated accordingly.
1708
     *
1709
     * @param array $response the response given by the student
1710
     * @return array updated response
1711
     */
1712
    public function translate_mc_answers_for_feedback(array $response): array {
1713
        // First, we fetch all answer boxes.
1714
        $boxes = self::scan_for_answer_boxes($this->subqtext);
357✔
1715

1716
        foreach ($boxes as $key => $box) {
357✔
1717
            // If it is not a multiple choice answer, we have nothing to do.
1718
            if ($box['options'] === '') {
168✔
1719
                continue;
84✔
1720
            }
1721

1722
            // Name of the array containing the choices.
1723
            $source = $box['options'];
84✔
1724

1725
            // Student's choice.
1726
            $userschoice = $response["{$this->partindex}$key"];
84✔
1727

1728
            // Fetch the value.
1729
            $parser = new parser("{$source}[$userschoice]");
84✔
1730
            try {
1731
                $result = $this->evaluator->evaluate($parser->get_statements()[0]);
84✔
1732
                $response["{$this->partindex}$key"] = $result->value;
84✔
1733
            } catch (Exception $e) {
21✔
1734
                // TODO: convert to non-capturing catch
1735
                // If there was an error, we leave the value as it is. This should
1736
                // not happen, because upon creation of the question, we check whether
1737
                // the variable containing the choices exists.
1738
                debugging('Could not translate multiple choice index back to its value. This should not happen. ' .
21✔
1739
                    'Please file a bug report.');
21✔
1740
            }
1741
        }
1742

1743
        return $response;
357✔
1744
    }
1745

1746

1747
    /**
1748
     * If the part is not correctly answered, we will set all answers to the empty string. Otherwise, we
1749
     * just return an empty array. This function will be called by the main question (for every part) and
1750
     * will be used to reset answers from wrong parts.
1751
     *
1752
     * @param array $response student's response
1753
     * @return array either an empty array (if part is correct) or an array with all answers being the empty string
1754
     */
1755
    public function clear_from_response_if_wrong(array $response): array {
1756
        // First, we have the response graded.
1757
        list('answer' => $answercorrect, 'unit' => $unitcorrect) = $this->grade($response);
42✔
1758

1759
        // If the part's answer is correct (including the unit, if any), we return an empty array.
1760
        // The caller of this function uses our values to overwrite the ones in the response, so
1761
        // that's fine.
1762
        if ($answercorrect >= 0.999 && $unitcorrect) {
42✔
1763
            return [];
21✔
1764
        }
1765

1766
        $result = [];
42✔
1767
        foreach (array_keys($this->get_expected_data()) as $key) {
42✔
1768
            $result[$key] = '';
42✔
1769
        }
1770
        return $result;
42✔
1771
    }
1772

1773

1774
}
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