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

FormulasQuestion / moodle-qtype_formulas / 14473980547

15 Apr 2025 03:59PM UTC coverage: 95.886% (+0.08%) from 95.807%
14473980547

Pull #181

github

web-flow
Merge 95093d2e3 into 8fed4a624
Pull Request #181: Write compatibility layer for unit expressions

150 of 153 new or added lines in 4 files covered. (98.04%)

3916 of 4084 relevant lines covered (95.89%)

1564.19 hits per line

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

98.02
/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\unit_parser;
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');
×
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'])) {
1,218✔
116
            return question_engine::make_behaviour('adaptivemultipart', $qa, $preferredbehaviour);
273✔
117
        }
118

119
        // Otherwise, pass it on to the parent class.
120
        return parent::make_behaviour($qa, $preferredbehaviour);
945✔
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 {@link question_attempt} being started
130
     * @param int $variant the variant requested, integer between 1 and {@link 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;
1,743✔
136
        $step->set_qt_var('_seed', $this->seed);
1,743✔
137

138
        // Create an empty evaluator, feed it with the random variables and instantiate
139
        // them.
140
        $this->evaluator = new evaluator();
1,743✔
141
        $randomparser = new random_parser($this->varsrandom);
1,743✔
142
        $this->evaluator->evaluate($randomparser->get_statements());
1,743✔
143
        $this->evaluator->instantiate_random_variables($this->seed);
1,743✔
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());
1,743✔
148
        $this->evaluator->evaluate($globalparser->get_statements());
1,743✔
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";
1,743✔
153
        $step->set_qt_var('_randomsvars_text', $legacynote . $this->evaluator->export_randomvars_for_step_data());
1,743✔
154
        $step->set_qt_var('_varsglobal', $legacynote . $this->varsglobal);
1,743✔
155

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

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

163
    /**
164
     * When reloading an in-progress {@link 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 {@link 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);
1,281✔
221
        $summary = $this->html_to_text($questiontext, $this->questiontextformat);
1,281✔
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) {
1,281✔
227
            $subqtext = $part->evaluator->substitute_variables_in_text($part->subqtext);
1,281✔
228
            $chunk = $this->html_to_text($subqtext, $part->subqtextformat);
1,281✔
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 !== '') {
1,281✔
232
                $summary = str_replace("{{$part->placeholder}}", $chunk, $summary);
126✔
233
            } else {
234
                $summary .= $chunk;
1,155✔
235
            }
236
        }
237
        return $summary;
1,281✔
238
    }
239

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

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

270
        // Call the corresponding function for each part and apply the union operator. Note that
271
        // the first argument takes precedence if a key exists in both arrays, so this will
272
        // replace all answers from $response that have been set in clear_from_response_if_wrong() and
273
        // keep all the others.
274
        foreach ($this->parts as $part) {
42✔
275
            $response = $part->clear_from_response_if_wrong($response) + $response;
42✔
276
        }
277

278
        return $response;
42✔
279
    }
280

281
    /**
282
     * Return the number of parts that have been correctly answered. The renderer will call this function
283
     * when the question is attempted in interactive mode with multiple tries *and* it is setup to show
284
     * the number of correct responses.
285
     *
286
     * @param array $response student's response
287
     * @return array array with [0] = number of correct parts and [1] = total number of parts
288
     */
289
    public function get_num_parts_right(array $response): array {
290
        // Normalize all student answers.
291
        $response = $this->normalize_response($response);
399✔
292

293
        $numcorrect = 0;
399✔
294
        foreach ($this->parts as $part) {
399✔
295
            list('answer' => $answercorrect, 'unit' => $unitcorrect) = $part->grade($response);
399✔
296

297
            if ($answercorrect >= 0.999 && $unitcorrect == true) {
399✔
298
                $numcorrect++;
210✔
299
            }
300
        }
301
        return [$numcorrect, $this->numparts];
399✔
302
    }
303

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

320
    /**
321
     * Return the model answers as entered by the teacher. These answers should normally be sufficient
322
     * to get the maximum grade.
323
     *
324
     * @param qtype_formulas_part|null model answer for every answer / unit box of each part
325
     * @return array model answer for every answer / unit box of each part
326
     */
327
    public function get_correct_response(?qtype_formulas_part $part = null): array {
328
        // If the caller has requested one specific part, just return that response.
329
        if (isset($part)) {
1,302✔
330
            return $part->get_correct_response();
63✔
331
        }
332

333
        // Otherwise, fetch them all.
334
        $responses = [];
1,302✔
335
        foreach ($this->parts as $part) {
1,302✔
336
            $responses += $part->get_correct_response();
1,302✔
337
        }
338
        return $responses;
1,302✔
339
    }
340

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

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

383
        // Files from the part's question text should be shown if the part ID matches one of our parts.
384
        if ($component === 'qtype_formulas' && $filearea === 'answersubqtext') {
105✔
385
            foreach ($this->parts as $part) {
21✔
386
                if ($part->id == $itemid) {
21✔
387
                    return true;
21✔
388
                }
389
            }
390
            // If we did not find a matching part, we don't serve the file.
391
            return false;
21✔
392
        }
393

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

409
            // If the question is not finished, check if we have a gradable response. If we do,
410
            // calculate the grade and proceed. Otherwise, do not grant access to feedback files.
411
            $state = $qa->get_state();
63✔
412
            if (!$state->is_finished()) {
63✔
413
                $response = $qa->get_last_qt_data();
63✔
414
                if (!$this->is_gradable_response($response)) {
63✔
415
                    return false;
63✔
416
                }
417
                // Response is gradable, so try to grade and get the corresponding state.
418
                list($ignored, $state) = $this->grade_response($response);
21✔
419
            }
420

421
            // Files from the answerfeedback area belong to the part's general feedback. It is showed
422
            // for all answers, if feedback is enabled in the display options.
423
            if ($filearea === 'answerfeedback') {
63✔
424
                return $options->generalfeedback;
21✔
425
            }
426

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

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

435
        $combinedfeedbackareas = ['correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'];
63✔
436
        if ($component === 'question' && in_array($filearea, $combinedfeedbackareas)) {
63✔
437
            return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args);
21✔
438
        }
439

440
        if ($component === 'question' && $filearea === 'hint') {
42✔
441
            return $this->check_hint_file_access($qa, $options, $args);
21✔
442
        }
443

444
        return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload);
21✔
445
    }
446

447
    /**
448
     * Used by many of the behaviours to determine whether the student has provided enough of an answer
449
     * for the question to be graded automatically, or whether it must be considered aborted.
450
     *
451
     * @param array $response responses, as returned by {@link question_attempt_step::get_qt_data()}
452
     * @return bool whether this response can be graded
453
     */
454
    public function is_gradable_response(array $response): bool {
455
        // Iterate over all parts. If one is not gradable, we return early.
456
        foreach ($this->parts as $part) {
546✔
457
            if (!$part->is_gradable_response($response)) {
546✔
458
                return false;
357✔
459
            }
460
        }
461

462
        // Still here? Then the question is gradable.
463
        return true;
483✔
464
    }
465

466
    /**
467
     * Used by many of the behaviours, to work out whether the student's response to the question is
468
     * complete. That is, whether the question attempt should move to the COMPLETE or INCOMPLETE state.
469
     *
470
     * @param array $response responses, as returned by {@link question_attempt_step::get_qt_data()}
471
     * @return bool whether this response is a complete answer to this question
472
     */
473
    public function is_complete_response(array $response): bool {
474
        // Iterate over all parts. If one part is not complete, we can return early.
475
        foreach ($this->parts as $part) {
1,533✔
476
            if (!$part->is_complete_response($response)) {
1,533✔
477
                return false;
462✔
478
            }
479
        }
480

481
        // Still here? Then all parts have been fully answered.
482
        return true;
1,197✔
483
    }
484

485
    /**
486
     * Used by many of the behaviours to determine whether the student's response has changed. This
487
     * is normally used to determine that a new set of responses can safely be discarded.
488
     *
489
     * @param array $prevresponse previously recorded responses, as returned by {@link question_attempt_step::get_qt_data()}
490
     * @param array $newresponse new responses, in the same format
491
     * @return bool whether the two sets of responses are the same
492
     */
493
    public function is_same_response(array $prevresponse, array $newresponse) {
494
        // Check each part. If there is a difference in one part, we leave early.
495
        foreach ($this->parts as $part) {
273✔
496
            if (!$part->is_same_response($prevresponse, $newresponse)) {
273✔
497
                return false;
273✔
498
            }
499
        }
500

501
        // Still here? Then it's the same response.
502
        return true;
84✔
503
    }
504

505
    /**
506
     * Produce a plain text summary of a response to be used e. g. in reports.
507
     *
508
     * @param array $response student's response, as might be passed to {@link grade_response()}
509
     * @return string plain text summary
510
     */
511
    public function summarise_response(array $response) {
512
        $summary = [];
1,260✔
513

514
        // Summarise each part's answers.
515
        foreach ($this->parts as $part) {
1,260✔
516
            $summary[] = $part->summarise_response($response);
1,260✔
517
        }
518
        return implode(', ', $summary);
1,260✔
519
    }
520

521
    /**
522
     * Categorise the student's response according to the categories defined by get_possible_responses.
523
     *
524
     * @param array $response response, as might be passed to {@link grade_response()}
525
     * @return array subpartid => {@link question_classified_response} objects;  empty array if no analysis is possible
526
     */
527
    public function classify_response(array $response) {
528
        // First, we normalize the student's answers.
529
        $response = $this->normalize_response($response);
483✔
530

531
        $classification = [];
483✔
532
        // Now, we do the classification for every part.
533
        foreach ($this->parts as $part) {
483✔
534
            // Unanswered parts can immediately be classified.
535
            if ($part->is_unanswered($response)) {
483✔
536
                $classification[$part->partindex] = question_classified_response::no_response();
126✔
537
                continue;
126✔
538
            }
539

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

543
            if ($part->postunit !== '') {
399✔
544
                // The unit can only be correct (1.0) or wrong (0.0).
545
                // The answer can be any float from 0.0 to 1.0 inclusive.
546
                if ($answergrade >= 0.999 && $unitcorrect) {
210✔
547
                    $classification[$part->partindex] = new question_classified_response(
42✔
548
                            'right', $part->summarise_response($response), 1);
42✔
549
                } else if ($unitcorrect) {
168✔
550
                    $classification[$part->partindex] = new question_classified_response(
42✔
551
                            'wrongvalue', $part->summarise_response($response), 0);
42✔
552
                } else if ($answergrade >= 0.999) {
126✔
553
                    $classification[$part->partindex] = new question_classified_response(
42✔
554
                            'wrongunit', $part->summarise_response($response), 1 - $part->unitpenalty);
42✔
555
                } else {
556
                    $classification[$part->partindex] = new question_classified_response(
138✔
557
                            'wrong', $part->summarise_response($response), 0);
138✔
558
                }
559
            } else {
560
                if ($answergrade >= .999) {
189✔
561
                    $classification[$part->partindex] = new question_classified_response(
126✔
562
                            'right', $part->summarise_response($response), $answergrade);
126✔
563
                } else {
564
                     $classification[$part->partindex] = new question_classified_response(
126✔
565
                            'wrong', $part->summarise_response($response), $answergrade);
126✔
566
                }
567
            }
568
        }
569
        return $classification;
483✔
570
    }
571

572
    /**
573
     * This method is called by the renderer when the question is in "invalid" state, i. e. if it
574
     * does not have a complete response (for immediate feedback or interactive mode) or if it has
575
     * an invalid part (in adaptive multipart mode).
576
     *
577
     * @param array $response student's response
578
     * @return string error message
579
     */
580
    public function get_validation_error(array $response): string {
581
        // If is_any_part_invalid() is true, that means no part is gradable, i. e. no fields
582
        // have been filled.
583
        if ($this->is_any_part_invalid($response)) {
105✔
584
            return get_string('allfieldsempty', 'qtype_formulas');
63✔
585
        }
586

587
        // If at least one part is gradable and yet the question is in "invalid" state, that means
588
        // that the behaviour expected all fields to be filled.
589
        return get_string('pleaseputananswer', 'qtype_formulas');
42✔
590
    }
591

592
    /**
593
     * Grade a response to the question, returning a fraction between get_min_fraction()
594
     * and 1.0, and the corresponding {@link question_state} right, partial or wrong. This
595
     * method is used with immediate feedback, with adaptive mode and with interactive mode. It
596
     * is called after the studenet clicks "submit and finish" when deferred feedback is active.
597
     *
598
     * @param array $response responses, as returned by {@link question_attempt_step::get_qt_data()}
599
     * @return array [0] => fraction (grade) and [1] => corresponding question state
600
     */
601
    public function grade_response(array $response) {
602
        $response = $this->normalize_response($response);
1,029✔
603

604
        $totalpossible = 0;
1,029✔
605
        $achievedmarks = 0;
1,029✔
606
        // Separately grade each part.
607
        foreach ($this->parts as $part) {
1,029✔
608
            // Count the total number of points for this part.
609
            $totalpossible += $part->answermark;
1,029✔
610

611
            $partsgrade = $part->grade($response);
1,029✔
612
            $fraction = $partsgrade['answer'];
1,029✔
613
            // If unit is wrong, make the necessary deduction.
614
            if ($partsgrade['unit'] === false) {
1,029✔
615
                $fraction = $fraction * (1 - $part->unitpenalty);
315✔
616
            }
617

618
            // Add the number of points achieved to the total.
619
            $achievedmarks += $part->answermark * $fraction;
1,029✔
620
        }
621

622
        // Finally, calculate the overall fraction of points received vs. possible points
623
        // and return the fraction together with the correct question state (i. e. correct,
624
        // partiall correct or wrong).
625
        $fraction = $achievedmarks / $totalpossible;
1,029✔
626
        return [$fraction, question_state::graded_state_for_fraction($fraction)];
1,029✔
627
    }
628

629
    /**
630
     * This method is called in multipart adaptive mode to grade the of the question
631
     * that can be graded. It returns the grade and penalty for each part, if (and only if)
632
     * the answer to that part has been changed since the last try. For parts that were
633
     * not retried, no grade or penalty should be returned.
634
     *
635
     * @param array $response current response (all fields)
636
     * @param array $lastgradedresponses array containing the (full) response given when each part registered
637
     *      an attempt for the last time; if there has been no try for a certain part, the corresponding key
638
     *      will be missing. Note that this is not the "history record" of all tries.
639
     * @param bool $finalsubmit true when the student clicks "submit all and finish"
640
     * @return array part name => qbehaviour_adaptivemultipart_part_result
641
     */
642
    public function grade_parts_that_can_be_graded(array $response, array $lastgradedresponses, $finalsubmit) {
643
        $partresults = [];
462✔
644

645
        foreach ($this->parts as $part) {
462✔
646
            // Check whether we already have an attempt for this part. If we don't, we create an
647
            // empty response.
648
            $lastresponse = [];
462✔
649
            if (array_key_exists($part->partindex, $lastgradedresponses)) {
462✔
650
                $lastresponse = $lastgradedresponses[$part->partindex];
315✔
651
            }
652

653
            // Check whether the response has been changed since the last attempt. If it has not,
654
            // we are done for this part.
655
            if ($part->is_same_response($lastresponse, $response)) {
462✔
656
                continue;
189✔
657
            }
658

659
            $partsgrade = $part->grade($response);
441✔
660
            $fraction = $partsgrade['answer'];
441✔
661
            // If unit is wrong, make the necessary deduction.
662
            if ($partsgrade['unit'] === false) {
441✔
663
                $fraction = $fraction * (1 - $part->unitpenalty);
63✔
664
            }
665

666
            $partresults[$part->partindex] = new qbehaviour_adaptivemultipart_part_result(
441✔
667
                $part->partindex, $fraction, $this->penalty
441✔
668
            );
441✔
669
        }
670

671
        return $partresults;
462✔
672
    }
673

674
    /**
675
     * Get a list of all the parts of the question and the weight they have within
676
     * the question.
677
     *
678
     * @return array part identifier => weight
679
     */
680
    public function get_parts_and_weights() {
681
        // First, we calculate the sum of all marks.
682
        $sum = 0;
336✔
683
        foreach ($this->parts as $part) {
336✔
684
            $sum += $part->answermark;
336✔
685
        }
686

687
        // Now that the total is known, we calculate each part's weight.
688
        $weights = [];
336✔
689
        foreach ($this->parts as $part) {
336✔
690
            $weights[$part->partindex] = $part->answermark / $sum;
336✔
691
        }
692

693
        return $weights;
336✔
694
    }
695

696
    /**
697
     * Check whether two responses for a given part (and only for that part) are identical.
698
     * This is used when working with multiple tries in order to avoid getting a penalty
699
     * deduction for an unchanged wrong answer that has already been counted before.
700
     *
701
     * @param string $id part indentifier
702
     * @param array $prevresponse previously recorded responses (for entire question)
703
     * @param array $newresponse new responses (for entire question)
704
     * @return bool
705
     */
706
    public function is_same_response_for_part($id, array $prevresponse, array $newresponse): bool {
707
        return $this->parts[$id]->is_same_response($prevresponse, $newresponse);
63✔
708
    }
709

710
    /**
711
     * This is called by adaptive multiplart behaviour in order to determine whether the question
712
     * state should be moved to question_state::$invalid; many behaviours mainly or exclusively
713
     * use !is_complete_response() for that. We will return true if *no* part is gradable,
714
     * because in that case it does not make sense to proceed. If at least one part has been
715
     * answered (at least partially), we say that no part is invalid, because that allows the student
716
     * to get feedback for the answered parts.
717
     *
718
     * @param array $response student's response
719
     * @return bool returning false
720
     */
721
    public function is_any_part_invalid(array $response): bool {
722
        // Iterate over all parts. If at least one part is gradable, we can leave early.
723
        foreach ($this->parts as $part) {
315✔
724
            if ($part->is_gradable_response($response)) {
315✔
725
                return false;
315✔
726
            }
727
        }
728

729
        return true;
63✔
730
    }
731

732
    /**
733
     * Work out a final grade for this attempt, taking into account all the tries the student made.
734
     * This method is called in interactive mode when all tries are done or when the user hits
735
     * 'Submit and finish'.
736
     *
737
     * @param array $responses response for each try, each element (1 <= n <= $totaltries) is a response array
738
     * @param int $totaltries maximum number of tries allowed
739
     * @return float grade that should be awarded for this sequence of responses
740
     */
741
    public function compute_final_grade($responses, $totaltries): float {
742
        $obtainedgrade = 0;
168✔
743
        $maxgrade = 0;
168✔
744

745
        foreach ($this->parts as $part) {
168✔
746
            $maxgrade += $part->answermark;
168✔
747

748
            // We start with an empty last response.
749
            $lastresponse = [];
168✔
750
            $lastchange = 0;
168✔
751

752
            $partfraction = 0;
168✔
753

754
            foreach ($responses as $responseindex => $response) {
168✔
755
                // If the response has not changed, we have nothing to do.
756
                if ($part->is_same_response($lastresponse, $response)) {
168✔
757
                    continue;
63✔
758
                }
759

760
                $response = $this->normalize_response($response);
168✔
761

762
                // Otherwise, save this as the last response and store the index where
763
                // the response was changed for the last time.
764
                $lastresponse = $response;
168✔
765
                $lastchange = $responseindex;
168✔
766

767
                // Obtain the grade for the current response.
768
                $partgrade = $part->grade($response);
168✔
769

770
                $partfraction = $partgrade['answer'];
168✔
771
                // If unit is wrong, make the necessary deduction.
772
                if ($partgrade['unit'] === false) {
168✔
773
                    $partfraction = $partfraction * (1 - $part->unitpenalty);
84✔
774
                }
775
            }
776
            $obtainedgrade += $part->answermark * max(0,  $partfraction - $lastchange * $this->penalty);
168✔
777
        }
778

779
        return $obtainedgrade / $maxgrade;
168✔
780
    }
781

782
    /**
783
     * Set up an evaluator class for every part and have it evaluate the local variables.
784
     *
785
     * @return void
786
     */
787
    public function initialize_part_evaluators(): void {
788
        // For every part, we clone the question's evaluator in order to have the
789
        // same set of (instantiated) random and global variables.
790
        foreach ($this->parts as $part) {
1,743✔
791
            $part->evaluator = clone $this->evaluator;
1,743✔
792

793
            // Parse and evaluate the local variables, if there are any. We do not need to
794
            // retrieve or store the result, because the vars will be set inside the evaluator.
795
            if (!empty($part->vars1)) {
1,743✔
796
                $parser = new parser($part->vars1);
42✔
797
                $part->evaluator->evaluate($parser->get_statements());
42✔
798
            }
799

800
            // Parse, evaluate and store the model answers. They will be returned as tokens,
801
            // so we need to "unpack" them. We always store the model answers as an array; if
802
            // there is only one answer, we wrap the value into an array.
803
            $part->get_evaluated_answers();
1,743✔
804
        }
805
    }
806

807
    /**
808
     * Normalize student response for each part, i. e. split number and unit for combined answer
809
     * fields, trim answers and set missing answers to empty string to make sure all expected
810
     * response fields are set.
811
     *
812
     * @param array $response the student's response
813
     * @return array normalized response
814
     */
815
    public function normalize_response(array $response): array {
816
        $result = [];
1,302✔
817

818
        // Normalize the responses for each part.
819
        foreach ($this->parts as $part) {
1,302✔
820
            $result += $part->normalize_response($response);
1,302✔
821
        }
822

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

827
        return $result;
1,302✔
828
    }
829
}
830

831
/**
832
 * Class to represent a question subpart, loaded from the question_answers table
833
 * in the database.
834
 *
835
 * @copyright  2012 Jean-Michel Védrine, 2023 Philipp Imhof
836
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
837
 */
838
class qtype_formulas_part {
839

840
    /** @var ?evaluator the part's evaluator class */
841
    public ?evaluator $evaluator = null;
842

843
    /** @var array store the evaluated model answer(s) */
844
    public array $evaluatedanswers = [];
845

846
    /** @var int the part's id */
847
    public int $id;
848

849
    /** @var int the parents question's id */
850
    public int $questionid;
851

852
    /** @var int the part's position among all parts of the question */
853
    public int $partindex;
854

855
    /** @var string the part's placeholder, e.g. #1 */
856
    public string $placeholder;
857

858
    /** @var float the maximum grade for this part */
859
    public float $answermark;
860

861
    /** @var int answer type (number, numerical, numerical formula, algebraic) */
862
    public int $answertype;
863

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

867
    /** @var string definition of local variables */
868
    public string $vars1;
869

870
    /** @var string definition of grading variables */
871
    public string $vars2;
872

873
    /** @var string definition of the model answer(s) */
874
    public string $answer;
875

876
    /** @var int whether there are multiple possible answers */
877
    public int $answernotunique;
878

879
    /** @var string definition of the grading criterion */
880
    public string $correctness;
881

882
    /** @var float deduction for a wrong unit */
883
    public float $unitpenalty;
884

885
    /** @var string unit */
886
    public string $postunit;
887

888
    /** @var int the set of basic unit conversion rules to be used */
889
    public int $ruleid;
890

891
    /** @var string additional conversion rules for other accepted base units */
892
    public string $otherrule;
893

894
    /** @var string the part's text */
895
    public string $subqtext;
896

897
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
898
    public int $subqtextformat;
899

900
    /** @var string general feedback for the part */
901
    public string $feedback;
902

903
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
904
    public int $feedbackformat;
905

906
    /** @var string part's feedback for any correct response */
907
    public string $partcorrectfb;
908

909
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
910
    public int $partcorrectfbformat;
911

912
    /** @var string part's feedback for any partially correct response */
913
    public string $partpartiallycorrectfb;
914

915
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
916
    public int $partpartiallycorrectfbformat;
917

918
    /** @var string part's feedback for any incorrect response */
919
    public string $partincorrectfb;
920

921
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
922
    public int $partincorrectfbformat;
923

924
    /**
925
     * Constructor.
926
     */
927
    public function __construct() {
928
    }
4,857✔
929

930
    /**
931
     * Whether or not a unit field is used in this part.
932
     *
933
     * @return bool
934
     */
935
    public function has_unit(): bool {
936
        return $this->postunit !== '';
2,037✔
937
    }
938

939
    /**
940
     * Whether or not the part has a combined input field for the number and the unit.
941
     *
942
     * @return bool
943
     */
944
    public function has_combined_unit_field(): bool {
945
        // In order to have a combined unit field, we must first assure that:
946
        // - there is a unit
947
        // - there is not more than one answer box
948
        // - the answer is not of the type algebraic formula.
949
        if (!$this->has_unit() || $this->numbox > 1 || $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
2,037✔
950
            return false;
1,407✔
951
        }
952

953
        // Furthermore, there must be either a {_0}{_u} without whitespace in the part's text
954
        // (meaning the user explicitly wants a combined unit field) or no answer box placeholders
955
        // at all, neither for the answer nor for the unit.
956
        $combinedrequested = strpos($this->subqtext, '{_0}{_u}') !== false;
777✔
957
        $noplaceholders = strpos($this->subqtext, '{_0}') === false && strpos($this->subqtext, '{_u}') === false;
777✔
958
        return $combinedrequested || $noplaceholders;
777✔
959
    }
960

961
    /**
962
     * Whether or not the part has a separate input field for the unit.
963
     *
964
     * @return bool
965
     */
966
    public function has_separate_unit_field(): bool {
967
        return $this->has_unit() && !$this->has_combined_unit_field();
1,491✔
968
    }
969

970
    /**
971
     * Check whether the previous response and the new response are the same for this part's fields.
972
     *
973
     * @param array $prevresponse previously recorded responses (for entire question)
974
     * @param array $newresponse new responses (for entire question)
975
     * @return bool
976
     */
977
    public function is_same_response(array $prevresponse, array $newresponse): bool {
978
        // Compare previous response and new response for every expected key.
979
        // If we have a difference at one point, we can return early.
980
        foreach (array_keys($this->get_expected_data()) as $key) {
651✔
981
            if (!question_utils::arrays_same_at_key_missing_is_blank($prevresponse, $newresponse, $key)) {
651✔
982
                return false;
630✔
983
            }
984
        }
985

986
        // Still here? That means they are all the same.
987
        return true;
294✔
988
    }
989

990
    /**
991
     * Return the expected fields and data types for all answer boxes this part. This function
992
     * is called by the main question's {@link get_expected_data()} method.
993
     *
994
     * @return array
995
     */
996
    public function get_expected_data(): array {
997
        // The combined unit field is only possible for parts with one
998
        // single answer box. If there are multiple input boxes, the
999
        // number and unit box will not be merged.
1000
        if ($this->has_combined_unit_field()) {
1,512✔
1001
            return ["{$this->partindex}_" => PARAM_RAW];
441✔
1002
        }
1003

1004
        // First, we expect the answers, counting from 0 to numbox - 1.
1005
        $expected = [];
1,176✔
1006
        for ($i = 0; $i < $this->numbox; $i++) {
1,176✔
1007
            $expected["{$this->partindex}_$i"] = PARAM_RAW;
1,176✔
1008
        }
1009

1010
        // If there is a separate unit field, we add it to the list.
1011
        if ($this->has_separate_unit_field()) {
1,176✔
1012
            $expected["{$this->partindex}_{$this->numbox}"] = PARAM_RAW;
336✔
1013
        }
1014
        return $expected;
1,176✔
1015
    }
1016

1017
    /**
1018
     * Parse a string (i. e. the part's text) looking for answer box placeholders.
1019
     * Answer box placeholders have one of the following forms:
1020
     * - {_u} for the unit box
1021
     * - {_n} for an answer box, n must be an integer
1022
     * - {_n:str} for radio buttons, str must be a variable name
1023
     * - {_n:str:MCE} for a drop down field, MCE must be verbatim
1024
     * Note: {_0:MCE} is valid and will lead to radio boxes based on the variable MCE.
1025
     * Every answer box in the array will itself be an associative array with the
1026
     * keys 'placeholder' (the entire placeholder), 'options' (the name of the variable containing
1027
     * the options for the radio list or the dropdown) and 'dropdown' (true or false).
1028
     * The method is declared static in order to allow its usage during form validation when
1029
     * there is no actual question object.
1030
     * TODO: allow {_n|50px} or {_n|10} to control size of the input field
1031
     *
1032
     * @param string $text string to be parsed
1033
     * @return array
1034
     */
1035
    public static function scan_for_answer_boxes(string $text): array {
1036
        // Match the text and store the matches.
1037
        preg_match_all('/\{(_u|_\d+)(:(_[A-Za-z]|[A-Za-z]\w*)(:(MCE))?)?\}/', $text, $matches);
4,059✔
1038

1039
        $boxes = [];
4,059✔
1040

1041
        // The array $matches[1] contains the matches of the first capturing group, i. e. _1 or _u.
1042
        foreach ($matches[1] as $i => $match) {
4,059✔
1043
            // Duplicates are not allowed.
1044
            if (array_key_exists($match, $boxes)) {
1,788✔
1045
                throw new Exception(get_string('error_answerbox_duplicate', 'qtype_formulas', $match));
147✔
1046
            }
1047
            // The array $matches[0] contains the entire pattern, e.g. {_1:var:MCE} or simply {_3}. This
1048
            // text is later needed to replace the placeholder by the input element.
1049
            // With $matches[3], we can access the name of the variable containing the options for the radio
1050
            // boxes or the drop down list.
1051
            // Finally, the array $matches[4] will contain ':MCE' in case this has been specified. Otherwise,
1052
            // there will be an empty string.
1053
            // TODO: add option 'size' (for characters) or 'width' (for pixel width).
1054
            $boxes[$match] = [
1,788✔
1055
                'placeholder' => $matches[0][$i],
1,788✔
1056
                'options' => $matches[3][$i],
1,788✔
1057
                'dropdown' => ($matches[4][$i] === ':MCE'),
1,788✔
1058
            ];
1,788✔
1059
        }
1060
        return $boxes;
3,912✔
1061
    }
1062

1063
    /**
1064
     * Produce a plain text summary of a response for the part.
1065
     *
1066
     * @param array $response a response, as might be passed to {@link grade_response()}.
1067
     * @return string a plain text summary of that response, that could be used in reports.
1068
     */
1069
    public function summarise_response(array $response) {
1070
        $summary = [];
1,260✔
1071

1072
        $isnormalized = array_key_exists('normalized', $response);
1,260✔
1073

1074
        // If the part has a combined unit field, we want to have the number and the unit
1075
        // to appear together in the summary.
1076
        if ($this->has_combined_unit_field()) {
1,260✔
1077
            // If the answer is normalized, the combined field has already been split, so we
1078
            // recombine both parts.
1079
            if ($isnormalized) {
483✔
1080
                return trim($response["{$this->partindex}_0"] . " " . $response["{$this->partindex}_1"]);
84✔
1081
            }
1082

1083
            // Otherwise, we check whether the key 0_ or similar is present in the response. If it is,
1084
            // we return that value.
1085
            if (isset($response["{$this->partindex}_"])) {
483✔
1086
                return $response["{$this->partindex}_"];
483✔
1087
            }
1088
        }
1089

1090
        // Iterate over all expected answer fields and if there is a corresponding
1091
        // answer in $response, add its value to the summary array.
1092
        foreach (array_keys($this->get_expected_data()) as $key) {
861✔
1093
            if (array_key_exists($key, $response)) {
861✔
1094
                $summary[] = $response[$key];
840✔
1095
            }
1096
        }
1097

1098
        // Transform the array to a comma-separated list for a nice summary.
1099
        return implode(', ', $summary);
861✔
1100
    }
1101

1102
    /**
1103
     * Normalize student response for current part, i. e. split number and unit for combined answer
1104
     * fields, trim answers and set missing answers to empty string to make sure all expected
1105
     * response fields are set.
1106
     *
1107
     * @param array $response student's full response
1108
     * @return array normalized response for this part only
1109
     */
1110
    public function normalize_response(array $response): array {
1111
        $result = [];
1,470✔
1112

1113
        // There might be a combined field for number and unit which would be called i_.
1114
        // We check this first. A combined field is only possible, if there is not more
1115
        // than one answer, so we can safely use i_0 for the number and i_1 for the unit.
1116
        $name = "{$this->partindex}_";
1,470✔
1117
        if (isset($response[$name])) {
1,470✔
1118
            $combined = trim($response[$name]);
525✔
1119

1120
            // We try to parse the student's response in order to find the position where
1121
            // the unit presumably starts. If parsing fails (e. g. because there is a syntax
1122
            // error), we consider the entire response to be the "number". It will later be graded
1123
            // wrong anyway.
1124
            try {
1125
                $parser = new answer_parser($combined);
525✔
1126
                $splitindex = $parser->find_start_of_units();
525✔
1127
            } catch (Throwable $t) {
21✔
1128
                // TODO: convert to non-capturing catch.
1129
                $splitindex = PHP_INT_MAX;
21✔
1130
            }
1131

1132
            $number = trim(substr($combined, 0, $splitindex));
525✔
1133
            $unit = trim(substr($combined, $splitindex));
525✔
1134

1135
            $result["{$name}0"] = $number;
525✔
1136
            $result["{$name}1"] = $unit;
525✔
1137
            return $result;
525✔
1138
        }
1139

1140
        // Otherwise, we iterate from 0 to numbox, inclusive (if there is a unit field) or exclusive.
1141
        $count = $this->numbox;
1,449✔
1142
        if ($this->has_unit()) {
1,449✔
1143
            $count++;
714✔
1144
        }
1145
        for ($i = 0; $i < $count; $i++) {
1,449✔
1146
            $name = "{$this->partindex}_$i";
1,449✔
1147

1148
            // If there is an answer, we strip white space from the start and end.
1149
            // Missing answers should be empty strings.
1150
            if (isset($response[$name])) {
1,449✔
1151
                $result[$name] = trim($response[$name]);
1,449✔
1152
            } else {
1153
                $result[$name] = '';
567✔
1154
            }
1155

1156
            // Restrict the answer's length to 128 characters. There is no real need
1157
            // for this, but it was done in the first versions, so we'll keep it for
1158
            // backwards compatibility.
1159
            if (strlen($result[$name]) > 128) {
1,449✔
1160
                $result[$name] = substr($result[$name], 0, 128);
21✔
1161
            }
1162
        }
1163

1164
        return $result;
1,449✔
1165
    }
1166

1167
    /**
1168
     * Determines whether the student has entered enough in order for this part to
1169
     * be graded. We consider a part gradable, if it is not unanswered, i. e. if at
1170
     * least some field has been filled.
1171
     *
1172
     * @param array $response
1173
     * @return bool
1174
     */
1175
    public function is_gradable_response(array $response): bool {
1176
        return !$this->is_unanswered($response);
546✔
1177
    }
1178

1179
    /**
1180
     * Determines whether the student has provided a complete answer to this part,
1181
     * i. e. if all fields have been filled. This method can be called before the
1182
     * response is normalized, so we cannot be sure all array keys exist as we would
1183
     * expect them.
1184
     *
1185
     * @param array $response
1186
     * @return bool
1187
     */
1188
    public function is_complete_response(array $response): bool {
1189
        // First, we check if there is a combined unit field. In that case, there will
1190
        // be only one field to verify.
1191
        if ($this->has_combined_unit_field()) {
1,533✔
1192
            return !empty($response["{$this->partindex}_"]) && strlen($response["{$this->partindex}_"]) > 0;
462✔
1193
        }
1194

1195
        // If we are still here, we do now check all "normal" fields. If one is empty,
1196
        // we can return early.
1197
        for ($i = 0; $i < $this->numbox; $i++) {
1,113✔
1198
            if (!isset($response["{$this->partindex}_{$i}"]) || strlen($response["{$this->partindex}_{$i}"]) == 0) {
1,113✔
1199
                return false;
357✔
1200
            }
1201
        }
1202

1203
        // Finally, we check whether there is a separate unit field and, if necessary,
1204
        // make sure it is not empty.
1205
        if ($this->has_separate_unit_field()) {
945✔
1206
            return !empty($response["{$this->partindex}_{$this->numbox}"])
189✔
1207
                && strlen($response["{$this->partindex}_{$this->numbox}"]) > 0;
189✔
1208
        }
1209

1210
        // Still here? That means no expected field was missing and no fields were empty.
1211
        return true;
798✔
1212
    }
1213

1214
    /**
1215
     * Determines whether the part (as a whole) is unanswered.
1216
     *
1217
     * @param array $response
1218
     * @return bool
1219
     */
1220
    public function is_unanswered(array $response): bool {
1221
        $isnormalized = array_key_exists('normalized', $response);
1,029✔
1222

1223
        // If there is a combined number/unit answer, we know that there are no other
1224
        // answers, so we just check this one. However, if the response has already been
1225
        // "normalized", we cannot use this shortcut, because then the combined unit field's
1226
        // content has already been split into _0 (the number) and _1 (the unit).
1227
        if ($isnormalized === false && $this->has_combined_unit_field()) {
1,029✔
1228
            // If the key does not exist, there is a problem and we consider the part as
1229
            // unanswered.
1230
            if (!array_key_exists("{$this->partindex}_", $response)) {
252✔
1231
                return true;
105✔
1232
            }
1233
            // If the answer is empty, but not equivalent to zero, we consider the part as
1234
            // unanswered.
1235
            $tocheck = $response["{$this->partindex}_"];
252✔
1236
            return empty($tocheck) && !is_numeric($tocheck);
252✔
1237
        }
1238

1239
        // Otherwise, we check all answer boxes (including a possible unit) of this part.
1240
        // If at least one is not empty, the part has been answered. If there is a unit field,
1241
        // we will check this in the same way, even if it should not actually be numeric. We don't
1242
        // need to care about that, because a wrong answer is still an answer.
1243
        // Note that $response will contain *all* answers for *all* parts.
1244
        $count = $this->numbox;
819✔
1245
        if ($this->has_unit()) {
819✔
1246
            $count++;
336✔
1247
        }
1248
        for ($i = 0; $i < $count; $i++) {
819✔
1249
            // If the key does not exist, there is a problem and we consider the part as
1250
            // unanswered.
1251
            if (!array_key_exists("{$this->partindex}_{$i}", $response)) {
819✔
1252
                return true;
252✔
1253
            }
1254
            // If the answer field is not empty or it is equivalent to zero, we consider
1255
            // the part as answered and leave early.
1256
            $tocheck = $response["{$this->partindex}_{$i}"];
756✔
1257
            if (!empty($tocheck) || is_numeric($tocheck)) {
756✔
1258
                return false;
672✔
1259
            }
1260
        }
1261

1262
        // Still here? Then no fields were filled.
1263
        return true;
126✔
1264
    }
1265

1266
    /**
1267
     * Return the part's evaluated answers. The teacher has probably entered them using the
1268
     * various (random, global and/or local) variables. This function calculates the numerical
1269
     * value of all answers. If the answer type is 'algebraic formula', the answers are not
1270
     * evaluated (this would not make sense), but the non-algebraic variables will be replaced
1271
     * by their respective values, e.g. "a*x^2" could become "2*x^2" if the variable a is defined
1272
     * to be 2.
1273
     *
1274
     * @return array
1275
     */
1276
    public function get_evaluated_answers(): array {
1277
        // If we already know the evaluated answers for this part, we can simply return them.
1278
        if (!empty($this->evaluatedanswers)) {
4,248✔
1279
            return $this->evaluatedanswers;
4,101✔
1280
        }
1281

1282
        // Still here? Then let's evaluate the answers.
1283
        $result = [];
4,248✔
1284

1285
        // Check whether the part uses the algebraic answer type.
1286
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
4,248✔
1287

1288
        $parser = new parser($this->answer, $this->evaluator->export_variable_list());
4,248✔
1289
        $result = $this->evaluator->evaluate($parser->get_statements())[0];
4,248✔
1290

1291
        // The $result will now be a token with its value being either a literal (string, number)
1292
        // or an array of literal tokens. If we have one single answer, we wrap it into an array
1293
        // before continuing. Otherwise we convert the array of tokens into an array of literals.
1294
        if ($result->type & token::ANY_LITERAL) {
4,248✔
1295
            $this->evaluatedanswers = [$result->value];
3,879✔
1296
        } else {
1297
            $this->evaluatedanswers = array_map(function ($element) {
369✔
1298
                return $element->value;
369✔
1299
            }, $result->value);
369✔
1300
        }
1301

1302
        // If the answer type is algebraic, substitute all non-algebraic variables by
1303
        // their numerical value.
1304
        if ($isalgebraic) {
4,248✔
1305
            foreach ($this->evaluatedanswers as &$answer) {
300✔
1306
                $answer = $this->evaluator->substitute_variables_in_algebraic_formula($answer);
300✔
1307
            }
1308
            // In case we later write to $answer, this would alter the last entry of the $modelanswers
1309
            // array, so we'd better remove the reference to make sure this won't happend.
1310
            unset($answer);
300✔
1311
        }
1312

1313
        return $this->evaluatedanswers;
4,248✔
1314
    }
1315

1316
    /**
1317
     * This function takes an array of algebraic formulas and wraps them in quotes. This is
1318
     * needed e.g. before we feed them to the parser for further processing.
1319
     *
1320
     * @param array $formulas the formulas to be wrapped in quotes
1321
     * @return array array containing wrapped formulas
1322
     */
1323
    private static function wrap_algebraic_formulas_in_quotes(array $formulas): array {
1324
        foreach ($formulas as &$formula) {
447✔
1325
            // If the formula is aready wrapped in quotes (e. g. after an earlier call to this
1326
            // function), there is nothing to do.
1327
            if (preg_match('/^\"[^\"]*\"$/', $formula)) {
447✔
1328
                continue;
63✔
1329
            }
1330

1331
            $formula = '"' . $formula . '"';
384✔
1332
        }
1333
        // In case we later write to $formula, this would alter the last entry of the $formulas
1334
        // array, so we'd better remove the reference to make sure this won't happen.
1335
        unset($formula);
447✔
1336

1337
        return $formulas;
447✔
1338
    }
1339

1340
    /**
1341
     * Check whether algebraic formulas contain a PREFIX operator.
1342
     *
1343
     * @param array $formulas the formulas to check
1344
     * @return bool
1345
     */
1346
    private static function contains_prefix_operator(array $formulas): bool {
1347
        foreach ($formulas as $formula) {
300✔
1348
            $lexer = new lexer($formula);
300✔
1349

1350
            foreach ($lexer->get_tokens() as $token) {
300✔
1351
                if ($token->type === token::PREFIX) {
300✔
1352
                    return true;
42✔
1353
                }
1354
            }
1355
        }
1356

1357
        return false;
279✔
1358
    }
1359

1360
    /**
1361
     * Add several special variables to the question part's evaluator, namely
1362
     * _a for the model answers (array)
1363
     * _r for the student's response (array)
1364
     * _d for the differences between _a and _r (array)
1365
     * _0, _1 and so on for the student's individual answers
1366
     * _err for the absolute error
1367
     * _relerr for the relative error, if applicable
1368
     *
1369
     * @param array $studentanswers the student's response
1370
     * @param float $conversionfactor unit conversion factor, if needed (1 otherwise)
1371
     * @param bool $formodelanswer whether we are doing this to test model answers, i. e. PREFIX operator is allowed
1372
     * @return void
1373
     */
1374
    public function add_special_variables(array $studentanswers, float $conversionfactor, bool $formodelanswer = false): void {
1375
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
3,849✔
1376

1377
        // First, we set _a to the array of model answers. We can use the
1378
        // evaluated answers. The function get_evaluated_answers() uses a cache.
1379
        // Answers of type alebraic formula must be wrapped in quotes.
1380
        $modelanswers = $this->get_evaluated_answers();
3,849✔
1381
        if ($isalgebraic) {
3,849✔
1382
            $modelanswers = self::wrap_algebraic_formulas_in_quotes($modelanswers);
300✔
1383
        }
1384
        $command = '_a = [' . implode(',', $modelanswers ). '];';
3,849✔
1385

1386
        // The variable _r will contain the student's answers, scaled according to the unit,
1387
        // but not containing the unit. Also, the variables _0, _1, ... will contain the
1388
        // individual answers.
1389
        if ($isalgebraic) {
3,849✔
1390
            // Students are not allowed to use the PREFIX operator. If they do, we drop out
1391
            // here. Throwing an exception will make sure the grading function awards zero points.
1392
            // Also, we disallow usage of the PREFIX in an algebraic formula's model answer, because
1393
            // that would lead to bad feedback (showing the student a "correct" answer that they cannot
1394
            // type in). In this case, we use a different error message.
1395
            if (self::contains_prefix_operator($studentanswers)) {
300✔
1396
                if ($formodelanswer) {
42✔
1397
                    throw new Exception(get_string('error_model_answer_prefix', 'qtype_formulas'));
21✔
1398
                }
1399
                throw new Exception(get_string('error_prefix', 'qtype_formulas'));
21✔
1400
            }
1401
            $studentanswers = self::wrap_algebraic_formulas_in_quotes($studentanswers);
279✔
1402
        }
1403
        $ssqstudentanswer = 0;
3,828✔
1404
        foreach ($studentanswers as $i => &$studentanswer) {
3,828✔
1405
            // We only do the calculation if the answer type is not algebraic. For algebraic
1406
            // answers, we don't do anything, because quotes have already been added.
1407
            if (!$isalgebraic) {
3,828✔
1408
                $studentanswer = $conversionfactor * $studentanswer;
3,570✔
1409
                $ssqstudentanswer += $studentanswer ** 2;
3,570✔
1410
            }
1411
            $command .= "_{$i} = {$studentanswer};";
3,828✔
1412
        }
1413
        unset($studentanswer);
3,828✔
1414
        $command .= '_r = [' . implode(',', $studentanswers) . '];';
3,828✔
1415

1416
        // The variable _d will contain the absolute differences between the model answer
1417
        // and the student's response. Using the parser's diff() function will make sure
1418
        // that algebraic answers are correctly evaluated.
1419
        $command .= '_d = diff(_a, _r);';
3,828✔
1420

1421
        // Prepare the variable _err which is the root of the sum of squared differences.
1422
        $command .= "_err = sqrt(sum(map('*', _d, _d)));";
3,828✔
1423

1424
        // Finally, calculate the relative error, unless the question uses an algebraic answer.
1425
        if (!$isalgebraic) {
3,828✔
1426
            // We calculate the sum of squares of all model answers.
1427
            $ssqmodelanswer = 0;
3,570✔
1428
            foreach ($this->get_evaluated_answers() as $answer) {
3,570✔
1429
                $ssqmodelanswer += $answer ** 2;
3,570✔
1430
            }
1431
            // If the sum of squares is 0 (i.e. all answers are 0), then either the student
1432
            // answers are all 0 as well, in which case we set the relative error to 0. Or
1433
            // they are not, in which case we set the relative error to the greatest possible value.
1434
            // Otherwise, the relative error is simply the absolute error divided by the root
1435
            // of the sum of squares.
1436
            if ($ssqmodelanswer == 0) {
3,570✔
1437
                $command .= '_relerr = ' . ($ssqstudentanswer == 0 ? 0 : PHP_FLOAT_MAX);
111✔
1438
            } else {
1439
                $command .= "_relerr = _err / sqrt({$ssqmodelanswer})";
3,459✔
1440
            }
1441
        }
1442

1443
        $parser = new parser($command, $this->evaluator->export_variable_list());
3,828✔
1444
        $this->evaluator->evaluate($parser->get_statements(), true);
3,828✔
1445
    }
1446

1447
    /**
1448
     * Grade the part and return its grade.
1449
     *
1450
     * @param array $response current response
1451
     * @param bool $finalsubmit true when the student clicks "submit all and finish"
1452
     * @return array 'answer' => grade (0...1) for this response, 'unit' => whether unit is correct (bool)
1453
     */
1454
    public function grade(array $response, bool $finalsubmit = false): array {
1455
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
1,386✔
1456

1457
        // Normalize the student's response for this part, removing answers from other parts.
1458
        $response = $this->normalize_response($response);
1,386✔
1459

1460
        // Store the unit as entered by the student and get rid of this part of the
1461
        // array, leaving only the inputs from the number fields.
1462
        $studentsunit = '';
1,386✔
1463
        if ($this->has_unit()) {
1,386✔
1464
            $studentsunit = trim($response["{$this->partindex}_{$this->numbox}"]);
693✔
1465
            unset($response["{$this->partindex}_{$this->numbox}"]);
693✔
1466
        }
1467

1468
        // For now, let's assume the unit is correct.
1469
        $unitcorrect = true;
1,386✔
1470

1471
        // Check whether the student's unit is compatible, i. e. whether it can be converted to
1472
        // the unit set by the teacher. If this is the case, we calculate the conversion factor.
1473
        // Otherwise, we set $unitcorrect to false and let the conversion factor be 1, so the
1474
        // result will not be "scaled".
1475
        $conversionfactor = $this->is_compatible_unit($studentsunit);
1,386✔
1476
        if ($conversionfactor === false) {
1,386✔
1477
            $conversionfactor = 1;
567✔
1478
            $unitcorrect = false;
567✔
1479
        }
1480

1481
        // The response array does not contain the unit anymore. If we are dealing with algebraic
1482
        // formulas, we must wrap the answers in quotes before we move on. Also, we reset the conversion
1483
        // factor, because it is not needed for algebraic answers.
1484
        if ($isalgebraic) {
1,386✔
1485
            $response = self::wrap_algebraic_formulas_in_quotes($response);
42✔
1486
            $conversionfactor = 1;
42✔
1487
        }
1488

1489
        // Now we iterate over all student answers, feed them to the parser and evaluate them in order
1490
        // to build an array containing the evaluated response.
1491
        $evaluatedresponse = [];
1,386✔
1492
        foreach ($response as $answer) {
1,386✔
1493
            try {
1494
                // Using the known variables upon initialisation allows the teacher to "block"
1495
                // certain built-in functions for the student by overwriting them, e. g. by
1496
                // defining "sin = 1" in the global variables.
1497
                $parser = new answer_parser($answer, $this->evaluator->export_variable_list());
1,386✔
1498

1499
                // Check whether the answer is valid for the given answer type. If it is not,
1500
                // we just throw an exception to make use of the catch block. Note that if the
1501
                // student's answer was empty, it will fail in this check.
1502
                if (!$parser->is_acceptable_for_answertype($this->answertype)) {
1,386✔
1503
                    throw new Exception();
651✔
1504
                }
1505

1506
                // Make sure the stack is empty, as there might be left-overs from a previous
1507
                // failed evaluation, e.g. caused by an invalid answer.
1508
                $this->evaluator->clear_stack();
1,344✔
1509

1510
                $evaluated = $this->evaluator->evaluate($parser->get_statements())[0];
1,344✔
1511
                $evaluatedresponse[] = token::unpack($evaluated);
1,344✔
1512
            } catch (Throwable $t) {
651✔
1513
                // TODO: convert to non-capturing catch
1514
                // If parsing, validity check or evaluation fails, we consider the answer as wrong.
1515
                // The unit might be correct, but that won't matter.
1516
                return ['answer' => 0, 'unit' => $unitcorrect];
651✔
1517
            }
1518
        }
1519

1520
        // Add correctness variables using the evaluated response.
1521
        try {
1522
            $this->add_special_variables($evaluatedresponse, $conversionfactor);
1,344✔
1523
        } catch (Exception $e) {
42✔
1524
            // TODO: convert to non-capturing catch
1525
            // If the special variables cannot be evaluated, the answer will be considered as
1526
            // wrong. Partial credit may be given for the unit. We do not carry on, because
1527
            // evaluation of the grading criterion (and possibly grading variables) generally
1528
            // depends on these special variables.
1529
            return ['answer' => 0, 'unit' => $unitcorrect];
42✔
1530
        }
1531

1532
        // Fetch and evaluate grading variables.
1533
        $gradingparser = new parser($this->vars2);
1,344✔
1534
        try {
1535
            $this->evaluator->evaluate($gradingparser->get_statements());
1,344✔
1536
        } catch (Exception $e) {
21✔
1537
            // TODO: convert to non-capturing catch
1538
            // If grading variables cannot be evaluated, the answer will be considered as
1539
            // wrong. Partial credit may be given for the unit. Thus, we do not need to
1540
            // carry on.
1541
            return ['answer' => 0, 'unit' => $unitcorrect];
21✔
1542
        }
1543

1544
        // Fetch and evaluate the grading criterion. If evaluation is not possible,
1545
        // set grade to 0.
1546
        $correctnessparser = new parser($this->correctness);
1,344✔
1547
        try {
1548
            $evaluatedgrading = $this->evaluator->evaluate($correctnessparser->get_statements())[0];
1,344✔
1549
            $evaluatedgrading = $evaluatedgrading->value;
1,344✔
1550
        } catch (Exception $e) {
21✔
1551
            // TODO: convert to non-capturing catch.
1552
            $evaluatedgrading = 0;
21✔
1553
        }
1554

1555
        // Restrict the grade to the closed interval [0,1].
1556
        $evaluatedgrading = min($evaluatedgrading, 1);
1,344✔
1557
        $evaluatedgrading = max($evaluatedgrading, 0);
1,344✔
1558

1559
        return ['answer' => $evaluatedgrading, 'unit' => $unitcorrect];
1,344✔
1560
    }
1561

1562
    /**
1563
     * Check whether the unit in the student's answer can be converted into the expected unit.
1564
     * TODO: refactor this once the unit system has been rewritten
1565
     *
1566
     * @param string $studentsunit unit provided by the student
1567
     * @return float|bool false if not compatible, conversion factor if compatible
1568
     */
1569
    private function is_compatible_unit(string $studentsunit) {
1570
        $checkunit = new answer_unit_conversion();
1,386✔
1571
        $conversionrules = new unit_conversion_rules();
1,386✔
1572
        $entry = $conversionrules->entry($this->ruleid);
1,386✔
1573
        $checkunit->assign_default_rules($this->ruleid, $entry[1]);
1,386✔
1574
        $checkunit->assign_additional_rules($this->otherrule);
1,386✔
1575

1576
        // Use the compatibility layer to parse the unit string and convert it into the old format.
1577
        // If parsing fails, we can immediately return false.
1578
        try {
1579
            $parser = new unit_parser($studentsunit);
1,386✔
1580
            $postunitparser = new unit_parser($this->postunit);
1,386✔
NEW
1581
        } catch (Exception $e) {
×
1582
            // TODO: convert to non-capturing catch
NEW
1583
            return false;
×
1584
        }
1585

1586
        $checked = $checkunit->check_convertibility(
1,386✔
1587
            $parser->get_legacy_unit_string(),
1,386✔
1588
            $postunitparser->get_legacy_unit_string(),
1,386✔
1589
        );
1,386✔
1590
        if ($checked->convertible) {
1,386✔
1591
            return $checked->cfactor;
1,113✔
1592
        }
1593

1594
        return false;
567✔
1595
    }
1596

1597
    /**
1598
     * Return an array containing the correct answers for this question part. If $forfeedback
1599
     * is set to true, multiple choice answers are translated from their list index to their
1600
     * value (e.g. the text) to provide feedback to the student. Also, quotes are stripped
1601
     * from algebraic formulas. Otherwise, the function returns the values as they are needed
1602
     * to obtain full mark at this question.
1603
     *
1604
     * @param bool $forfeedback whether we request correct answers for student feedback
1605
     * @return array list of correct answers
1606
     */
1607
    public function get_correct_response(bool $forfeedback = false): array {
1608
        // Fetch the evaluated answers.
1609
        $answers = $this->get_evaluated_answers();
1,302✔
1610

1611
        // If we have a combined unit field, we return both the model answer plus the unit
1612
        // in "i_". Combined fields are only possible for parts with one signle answer.
1613
        if ($this->has_combined_unit_field()) {
1,302✔
1614
            return ["{$this->partindex}_" => trim($answers[0] . ' ' . $this->postunit)];
483✔
1615
        }
1616

1617
        // Strip quotes around algebraic formulas, if the answers are used for feedback.
1618
        if ($forfeedback && $this->answertype === qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
882✔
1619
            $answers = str_replace('"', '', $answers);
21✔
1620
        }
1621

1622
        // Otherwise, we build an array with all answers, according to our naming scheme.
1623
        $res = [];
882✔
1624
        for ($i = 0; $i < $this->numbox; $i++) {
882✔
1625
            $res["{$this->partindex}_{$i}"] = $answers[$i];
882✔
1626
        }
1627

1628
        // Finally, if we have a separate unit field, we add this as well.
1629
        if ($this->has_separate_unit_field()) {
882✔
1630
            $res["{$this->partindex}_{$this->numbox}"] = $this->postunit;
273✔
1631
        }
1632

1633
        if ($forfeedback) {
882✔
1634
            $res = $this->translate_mc_answers_for_feedback($res);
210✔
1635
        }
1636

1637
        return $res;
882✔
1638
    }
1639

1640
    /**
1641
     * When using multichoice options in a question (e.g. radio buttons or a dropdown list),
1642
     * the answer will be stored as a number, e.g. 1 for the *second* option. However, when
1643
     * giving feedback to the student, we want to show the actual *value* of the option and not
1644
     * its number. This function makes sure the stored answer is translated accordingly.
1645
     *
1646
     * @param array $response the response given by the student
1647
     * @return array updated response
1648
     */
1649
    public function translate_mc_answers_for_feedback(array $response): array {
1650
        // First, we fetch all answer boxes.
1651
        $boxes = self::scan_for_answer_boxes($this->subqtext);
210✔
1652

1653
        foreach ($boxes as $key => $box) {
210✔
1654
            // If it is not a multiple choice answer, we have nothing to do.
1655
            if ($box['options'] === '') {
126✔
1656
                continue;
84✔
1657
            }
1658

1659
            // Name of the array containing the choices.
1660
            $source = $box['options'];
42✔
1661

1662
            // Student's choice.
1663
            $userschoice = $response["{$this->partindex}$key"];
42✔
1664

1665
            // Fetch the value.
1666
            $parser = new parser("{$source}[$userschoice]");
42✔
1667
            try {
1668
                $result = $this->evaluator->evaluate($parser->get_statements()[0]);
42✔
1669
                $response["{$this->partindex}$key"] = $result->value;
42✔
1670
            } catch (Exception $e) {
×
1671
                // TODO: convert to non-capturing catch
1672
                // If there was an error, we leave the value as it is. This should
1673
                // not happen, because upon creation of the question, we check whether
1674
                // the variable containing the choices exists.
1675
                debugging('Could not translate multiple choice index back to its value. This should not happen. ' .
×
1676
                    'Please file a bug report.');
×
1677
            }
1678
        }
1679

1680
        return $response;
210✔
1681
    }
1682

1683

1684
    /**
1685
     * If the part is not correctly answered, we will set all answers to the empty string. Otherwise, we
1686
     * just return an empty array. This function will be called by the main question (for every part) and
1687
     * will be used to reset answers from wrong parts.
1688
     *
1689
     * @param array $response student's response
1690
     * @return array either an empty array (if part is correct) or an array with all answers being the empty string
1691
     */
1692
    public function clear_from_response_if_wrong(array $response): array {
1693
        // First, we have the response graded.
1694
        list('answer' => $answercorrect, 'unit' => $unitcorrect) = $this->grade($response);
42✔
1695

1696
        // If the part's answer is correct (including the unit, if any), we return an empty array.
1697
        // The caller of this function uses our values to overwrite the ones in the response, so
1698
        // that's fine.
1699
        if ($answercorrect >= 0.999 && $unitcorrect) {
42✔
1700
            return [];
21✔
1701
        }
1702

1703
        $result = [];
42✔
1704
        foreach (array_keys($this->get_expected_data()) as $key) {
42✔
1705
            $result[$key] = '';
42✔
1706
        }
1707
        return $result;
42✔
1708
    }
1709
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc