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

FormulasQuestion / moodle-qtype_formulas / 15537037899

09 Jun 2025 02:31PM UTC coverage: 97.371% (-0.06%) from 97.435%
15537037899

Pull #228

github

web-flow
Merge b377a6497 into 3c12eaad6
Pull Request #228: Use localised numbers

16 of 19 new or added lines in 5 files covered. (84.21%)

1 existing line in 1 file now uncovered.

4037 of 4146 relevant lines covered (97.37%)

1580.36 hits per line

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

98.88
/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\unit_conversion_rules;
40

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

277
        return $response;
42✔
278
    }
279

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

461
        // Still here? Then the question is not gradable.
462
        return false;
441✔
463
    }
464

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

670
        return $partresults;
483✔
671
    }
672

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

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

692
        return $weights;
378✔
693
    }
694

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

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

728
        return true;
147✔
729
    }
730

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

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

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

751
            $partfraction = 0;
189✔
752

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

759
                $response = $this->normalize_response($response);
189✔
760

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

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

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

778
        return $obtainedgrade / $maxgrade;
189✔
779
    }
780

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

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

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

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

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

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

826
        return $result;
1,428✔
827
    }
828
}
829

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

923
    /**
924
     * Constructor.
925
     */
926
    public function __construct() {
927
    }
5,154✔
928

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

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

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

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

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

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

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

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

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

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

1038
        $boxes = [];
4,356✔
1039

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

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

1071
        $isnormalized = array_key_exists('normalized', $response);
1,491✔
1072

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

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

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

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

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

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

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

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

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

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

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

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

1163
        return $result;
1,722✔
1164
    }
1165

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

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

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

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

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

1213
    /**
1214
     * Determines whether the part (as a whole) is unanswered.
1215
     *
1216
     * @param array $response
1217
     * @return bool
1218
     */
1219
    public function is_unanswered(array $response): bool {
1220
        if (!array_key_exists('normalized', $response)) {
1,176✔
1221
            $response = $this->normalize_response($response);
693✔
1222
        }
1223

1224
        // Check all answer boxes (including a possible unit) of this part. If at least one is not empty,
1225
        // the part has been answered. If there is a unit field, we will check this in the same way, even
1226
        // if it should not actually be numeric. We don't need to care about that, because a wrong answer
1227
        // is still an answer. Note that $response will contain *all* answers for *all* parts.
1228
        $count = $this->numbox;
1,176✔
1229
        if ($this->has_unit()) {
1,176✔
1230
            $count++;
567✔
1231
        }
1232
        for ($i = 0; $i < $count; $i++) {
1,176✔
1233
            // If the answer field is not empty or it is equivalent to zero, we consider
1234
            // the part as answered and leave early.
1235
            $tocheck = $response["{$this->partindex}_{$i}"];
1,176✔
1236
            if (!empty($tocheck) || is_numeric($tocheck)) {
1,176✔
1237
                return false;
1,029✔
1238
            }
1239
        }
1240

1241
        // Still here? Then no fields were filled.
1242
        return true;
630✔
1243
    }
1244

1245
    /**
1246
     * Return the part's evaluated answers. The teacher has probably entered them using the
1247
     * various (random, global and/or local) variables. This function calculates the numerical
1248
     * value of all answers. If the answer type is 'algebraic formula', the answers are not
1249
     * evaluated (this would not make sense), but the non-algebraic variables will be replaced
1250
     * by their respective values, e.g. "a*x^2" could become "2*x^2" if the variable a is defined
1251
     * to be 2.
1252
     *
1253
     * @return array
1254
     */
1255
    public function get_evaluated_answers(): array {
1256
        // If we already know the evaluated answers for this part, we can simply return them.
1257
        if (!empty($this->evaluatedanswers)) {
4,503✔
1258
            return $this->evaluatedanswers;
4,356✔
1259
        }
1260

1261
        // Still here? Then let's evaluate the answers.
1262
        $result = [];
4,503✔
1263

1264
        // Check whether the part uses the algebraic answer type.
1265
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
4,503✔
1266

1267
        $parser = new parser($this->answer, $this->evaluator->export_variable_list());
4,503✔
1268
        $result = $this->evaluator->evaluate($parser->get_statements())[0];
4,503✔
1269

1270
        // The $result will now be a token with its value being either a literal (string, number)
1271
        // or an array of literal tokens. If we have one single answer, we wrap it into an array
1272
        // before continuing. Otherwise we convert the array of tokens into an array of literals.
1273
        if ($result->type & token::ANY_LITERAL) {
4,503✔
1274
            $this->evaluatedanswers = [$result->value];
4,134✔
1275
        } else {
1276
            $this->evaluatedanswers = array_map(function ($element) {
432✔
1277
                return $element->value;
432✔
1278
            }, $result->value);
432✔
1279
        }
1280

1281
        // If the answer type is algebraic, substitute all non-algebraic variables by
1282
        // their numerical value.
1283
        if ($isalgebraic) {
4,503✔
1284
            foreach ($this->evaluatedanswers as &$answer) {
300✔
1285
                $answer = $this->evaluator->substitute_variables_in_algebraic_formula($answer);
300✔
1286
            }
1287
            // In case we later write to $answer, this would alter the last entry of the $modelanswers
1288
            // array, so we'd better remove the reference to make sure this won't happend.
1289
            unset($answer);
300✔
1290
        }
1291

1292
        return $this->evaluatedanswers;
4,503✔
1293
    }
1294

1295
    /**
1296
     * This function takes an array of algebraic formulas and wraps them in quotes. This is
1297
     * needed e.g. before we feed them to the parser for further processing.
1298
     *
1299
     * @param array $formulas the formulas to be wrapped in quotes
1300
     * @return array array containing wrapped formulas
1301
     */
1302
    private static function wrap_algebraic_formulas_in_quotes(array $formulas): array {
1303
        foreach ($formulas as &$formula) {
468✔
1304
            // If the formula is aready wrapped in quotes (e. g. after an earlier call to this
1305
            // function), there is nothing to do.
1306
            if (preg_match('/^\"[^\"]*\"$/', $formula)) {
468✔
1307
                continue;
63✔
1308
            }
1309

1310
            $formula = '"' . $formula . '"';
405✔
1311
        }
1312
        // In case we later write to $formula, this would alter the last entry of the $formulas
1313
        // array, so we'd better remove the reference to make sure this won't happen.
1314
        unset($formula);
468✔
1315

1316
        return $formulas;
468✔
1317
    }
1318

1319
    /**
1320
     * Check whether algebraic formulas contain a PREFIX operator.
1321
     *
1322
     * @param array $formulas the formulas to check
1323
     * @return bool
1324
     */
1325
    private static function contains_prefix_operator(array $formulas): bool {
1326
        foreach ($formulas as $formula) {
321✔
1327
            $lexer = new lexer($formula);
321✔
1328

1329
            foreach ($lexer->get_tokens() as $token) {
321✔
1330
                if ($token->type === token::PREFIX) {
300✔
1331
                    return true;
42✔
1332
                }
1333
            }
1334
        }
1335

1336
        return false;
300✔
1337
    }
1338

1339
    /**
1340
     * Add several special variables to the question part's evaluator, namely
1341
     * _a for the model answers (array)
1342
     * _r for the student's response (array)
1343
     * _d for the differences between _a and _r (array)
1344
     * _0, _1 and so on for the student's individual answers
1345
     * _err for the absolute error
1346
     * _relerr for the relative error, if applicable
1347
     *
1348
     * @param array $studentanswers the student's response
1349
     * @param float $conversionfactor unit conversion factor, if needed (1 otherwise)
1350
     * @param bool $formodelanswer whether we are doing this to test model answers, i. e. PREFIX operator is allowed
1351
     * @return void
1352
     */
1353
    public function add_special_variables(array $studentanswers, float $conversionfactor, bool $formodelanswer = false): void {
1354
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
3,978✔
1355

1356
        // First, we set _a to the array of model answers. We can use the
1357
        // evaluated answers. The function get_evaluated_answers() uses a cache.
1358
        // Answers of type alebraic formula must be wrapped in quotes.
1359
        $modelanswers = $this->get_evaluated_answers();
3,978✔
1360
        if ($isalgebraic) {
3,978✔
1361
            $modelanswers = self::wrap_algebraic_formulas_in_quotes($modelanswers);
321✔
1362
        }
1363
        $command = '_a = [' . implode(',', $modelanswers ). '];';
3,978✔
1364

1365
        // The variable _r will contain the student's answers, scaled according to the unit,
1366
        // but not containing the unit. Also, the variables _0, _1, ... will contain the
1367
        // individual answers.
1368
        if ($isalgebraic) {
3,978✔
1369
            // Students are not allowed to use the PREFIX operator. If they do, we drop out
1370
            // here. Throwing an exception will make sure the grading function awards zero points.
1371
            // Also, we disallow usage of the PREFIX in an algebraic formula's model answer, because
1372
            // that would lead to bad feedback (showing the student a "correct" answer that they cannot
1373
            // type in). In this case, we use a different error message.
1374
            if (self::contains_prefix_operator($studentanswers)) {
321✔
1375
                if ($formodelanswer) {
42✔
1376
                    throw new Exception(get_string('error_model_answer_prefix', 'qtype_formulas'));
21✔
1377
                }
1378
                throw new Exception(get_string('error_prefix', 'qtype_formulas'));
21✔
1379
            }
1380
            $studentanswers = self::wrap_algebraic_formulas_in_quotes($studentanswers);
300✔
1381
        }
1382
        $ssqstudentanswer = 0;
3,957✔
1383
        foreach ($studentanswers as $i => &$studentanswer) {
3,957✔
1384
            // We only do the calculation if the answer type is not algebraic. For algebraic
1385
            // answers, we don't do anything, because quotes have already been added.
1386
            if (!$isalgebraic) {
3,957✔
1387
                $studentanswer = $conversionfactor * $studentanswer;
3,678✔
1388
                $ssqstudentanswer += $studentanswer ** 2;
3,678✔
1389
            }
1390
            $command .= "_{$i} = {$studentanswer};";
3,957✔
1391
        }
1392
        unset($studentanswer);
3,957✔
1393
        $command .= '_r = [' . implode(',', $studentanswers) . '];';
3,957✔
1394

1395
        // The variable _d will contain the absolute differences between the model answer
1396
        // and the student's response. Using the parser's diff() function will make sure
1397
        // that algebraic answers are correctly evaluated.
1398
        $command .= '_d = diff(_a, _r);';
3,957✔
1399

1400
        // Prepare the variable _err which is the root of the sum of squared differences.
1401
        $command .= "_err = sqrt(sum(map('*', _d, _d)));";
3,957✔
1402

1403
        // Finally, calculate the relative error, unless the question uses an algebraic answer.
1404
        if (!$isalgebraic) {
3,957✔
1405
            // We calculate the sum of squares of all model answers.
1406
            $ssqmodelanswer = 0;
3,678✔
1407
            foreach ($this->get_evaluated_answers() as $answer) {
3,678✔
1408
                $ssqmodelanswer += $answer ** 2;
3,678✔
1409
            }
1410
            // If the sum of squares is 0 (i.e. all answers are 0), then either the student
1411
            // answers are all 0 as well, in which case we set the relative error to 0. Or
1412
            // they are not, in which case we set the relative error to the greatest possible value.
1413
            // Otherwise, the relative error is simply the absolute error divided by the root
1414
            // of the sum of squares.
1415
            if ($ssqmodelanswer == 0) {
3,678✔
1416
                $command .= '_relerr = ' . ($ssqstudentanswer == 0 ? 0 : PHP_FLOAT_MAX);
111✔
1417
            } else {
1418
                $command .= "_relerr = _err / sqrt({$ssqmodelanswer})";
3,567✔
1419
            }
1420
        }
1421

1422
        $parser = new parser($command, $this->evaluator->export_variable_list());
3,957✔
1423
        $this->evaluator->evaluate($parser->get_statements(), true);
3,957✔
1424
    }
1425

1426
    /**
1427
     * Grade the part and return its grade.
1428
     *
1429
     * @param array $response current response
1430
     * @param bool $finalsubmit true when the student clicks "submit all and finish"
1431
     * @return array 'answer' => grade (0...1) for this response, 'unit' => whether unit is correct (bool)
1432
     */
1433
    public function grade(array $response, bool $finalsubmit = false): array {
1434
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
1,596✔
1435

1436
        // Normalize the student's response for this part, removing answers from other parts.
1437
        $response = $this->normalize_response($response);
1,596✔
1438

1439
        // Store the unit as entered by the student and get rid of this part of the
1440
        // array, leaving only the inputs from the number fields.
1441
        $studentsunit = '';
1,596✔
1442
        if ($this->has_unit()) {
1,596✔
1443
            $studentsunit = trim($response["{$this->partindex}_{$this->numbox}"]);
714✔
1444
            unset($response["{$this->partindex}_{$this->numbox}"]);
714✔
1445
        }
1446

1447
        // For now, let's assume the unit is correct.
1448
        $unitcorrect = true;
1,596✔
1449

1450
        // Check whether the student's unit is compatible, i. e. whether it can be converted to
1451
        // the unit set by the teacher. If this is the case, we calculate the conversion factor.
1452
        // Otherwise, we set $unitcorrect to false and let the conversion factor be 1, so the
1453
        // result will not be "scaled".
1454
        $conversionfactor = $this->is_compatible_unit($studentsunit);
1,596✔
1455
        if ($conversionfactor === false) {
1,596✔
1456
            $conversionfactor = 1;
609✔
1457
            $unitcorrect = false;
609✔
1458
        }
1459

1460
        // The response array does not contain the unit anymore. If we are dealing with algebraic
1461
        // formulas, we must wrap the answers in quotes before we move on. Also, we reset the conversion
1462
        // factor, because it is not needed for algebraic answers.
1463
        if ($isalgebraic) {
1,596✔
1464
                $response = self::wrap_algebraic_formulas_in_quotes($response);
63✔
1465
            $conversionfactor = 1;
63✔
1466
        }
1467

1468
        // Now we iterate over all student answers, feed them to the parser and evaluate them in order
1469
        // to build an array containing the evaluated response.
1470
        $evaluatedresponse = [];
1,596✔
1471
        foreach ($response as $answer) {
1,596✔
1472
            try {
1473
                // Using the known variables upon initialisation allows the teacher to "block"
1474
                // certain built-in functions for the student by overwriting them, e. g. by
1475
                // defining "sin = 1" in the global variables.
1476
                $parser = new answer_parser($answer, $this->evaluator->export_variable_list());
1,596✔
1477

1478
                // Check whether the answer is valid for the given answer type. If it is not,
1479
                // we just throw an exception to make use of the catch block. Note that if the
1480
                // student's answer was empty, it will fail in this check.
1481
                if (!$parser->is_acceptable_for_answertype($this->answertype)) {
1,596✔
1482
                    throw new Exception();
882✔
1483
                }
1484

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

1489
                $evaluated = $this->evaluator->evaluate($parser->get_statements())[0];
1,449✔
1490
                $evaluatedresponse[] = token::unpack($evaluated);
1,449✔
1491
            } catch (Throwable $t) {
882✔
1492
                // TODO: convert to non-capturing catch
1493
                // If parsing, validity check or evaluation fails, we consider the answer as wrong.
1494
                // The unit might be correct, but that won't matter.
1495
                return ['answer' => 0, 'unit' => $unitcorrect];
882✔
1496
            }
1497
        }
1498

1499
        // Add correctness variables using the evaluated response.
1500
        try {
1501
            $this->add_special_variables($evaluatedresponse, $conversionfactor);
1,449✔
1502
        } catch (Exception $e) {
63✔
1503
            // TODO: convert to non-capturing catch
1504
            // If the special variables cannot be evaluated, the answer will be considered as
1505
            // wrong. Partial credit may be given for the unit. We do not carry on, because
1506
            // evaluation of the grading criterion (and possibly grading variables) generally
1507
            // depends on these special variables.
1508
            return ['answer' => 0, 'unit' => $unitcorrect];
63✔
1509
        }
1510

1511
        // Fetch and evaluate grading variables.
1512
        $gradingparser = new parser($this->vars2);
1,428✔
1513
        try {
1514
            $this->evaluator->evaluate($gradingparser->get_statements());
1,428✔
1515
        } catch (Exception $e) {
21✔
1516
            // TODO: convert to non-capturing catch
1517
            // If grading variables cannot be evaluated, the answer will be considered as
1518
            // wrong. Partial credit may be given for the unit. Thus, we do not need to
1519
            // carry on.
1520
            return ['answer' => 0, 'unit' => $unitcorrect];
21✔
1521
        }
1522

1523
        // Fetch and evaluate the grading criterion. If evaluation is not possible,
1524
        // set grade to 0.
1525
        $correctnessparser = new parser($this->correctness);
1,428✔
1526
        try {
1527
            $evaluatedgrading = $this->evaluator->evaluate($correctnessparser->get_statements())[0];
1,428✔
1528
            $evaluatedgrading = $evaluatedgrading->value;
1,428✔
1529
        } catch (Exception $e) {
21✔
1530
            // TODO: convert to non-capturing catch.
1531
            $evaluatedgrading = 0;
21✔
1532
        }
1533

1534
        // Restrict the grade to the closed interval [0,1].
1535
        $evaluatedgrading = min($evaluatedgrading, 1);
1,428✔
1536
        $evaluatedgrading = max($evaluatedgrading, 0);
1,428✔
1537

1538
        return ['answer' => $evaluatedgrading, 'unit' => $unitcorrect];
1,428✔
1539
    }
1540

1541
    /**
1542
     * Check whether the unit in the student's answer can be converted into the expected unit.
1543
     * TODO: refactor this once the unit system has been rewritten
1544
     *
1545
     * @param string $studentsunit unit provided by the student
1546
     * @return float|bool false if not compatible, conversion factor if compatible
1547
     */
1548
    private function is_compatible_unit(string $studentsunit) {
1549
        $checkunit = new answer_unit_conversion();
1,596✔
1550
        $conversionrules = new unit_conversion_rules();
1,596✔
1551
        $entry = $conversionrules->entry($this->ruleid);
1,596✔
1552
        $checkunit->assign_default_rules($this->ruleid, $entry[1]);
1,596✔
1553
        $checkunit->assign_additional_rules($this->otherrule);
1,596✔
1554

1555
        $checked = $checkunit->check_convertibility($studentsunit, $this->postunit);
1,596✔
1556
        if ($checked->convertible) {
1,596✔
1557
            return $checked->cfactor;
1,323✔
1558
        }
1559

1560
        return false;
609✔
1561
    }
1562

1563
    /**
1564
     * Return an array containing the correct answers for this question part. If $forfeedback
1565
     * is set to true, multiple choice answers are translated from their list index to their
1566
     * value (e.g. the text) to provide feedback to the student. Also, quotes are stripped
1567
     * from algebraic formulas. Otherwise, the function returns the values as they are needed
1568
     * to obtain full mark at this question.
1569
     *
1570
     * @param bool $forfeedback whether we request correct answers for student feedback
1571
     * @return array list of correct answers
1572
     */
1573
    public function get_correct_response(bool $forfeedback = false): array {
1574
        // Fetch the evaluated answers.
1575
        $answers = $this->get_evaluated_answers();
1,533✔
1576

1577
        // Numeric answers should be localized, if that functionality is enabled.
1578
        foreach ($answers as &$answer) {
1,533✔
1579
            if (is_numeric($answer) && get_config('qtype_formulas', 'allowdecimalcomma')) {
1,533✔
NEW
1580
                $answer = format_float($answer, -1);
×
1581
            }
1582
        }
1583
        // Make sure we do not accidentally write to $answer later.
1584
        unset($answer);
1,533✔
1585

1586
        // If we have a combined unit field, we return both the model answer plus the unit
1587
        // in "i_". Combined fields are only possible for parts with one signle answer.
1588
        if ($this->has_combined_unit_field()) {
1,533✔
1589
            return ["{$this->partindex}_" => trim($answers[0] . ' ' . $this->postunit)];
525✔
1590
        }
1591

1592
        // As algebraic formulas are not numbers, we must replace the decimal point separately.
1593
        // Also, if the answer is requested for feedback, we must strip the quotes.
1594
        // Strip quotes around algebraic formulas, if the answers are used for feedback.
1595
        if ($this->answertype === qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
1,113✔
1596
            $answers = str_replace('.', get_string('decsep', 'langconfig'), $answers);
63✔
1597

1598
            if ($forfeedback) {
63✔
1599
                $answers = str_replace('"', '', $answers);
21✔
1600
            }
1601
        }
1602

1603
        // Otherwise, we build an array with all answers, according to our naming scheme.
1604
        $res = [];
1,113✔
1605
        for ($i = 0; $i < $this->numbox; $i++) {
1,113✔
1606
            $res["{$this->partindex}_{$i}"] = $answers[$i];
1,113✔
1607
        }
1608

1609
        // Finally, if we have a separate unit field, we add this as well.
1610
        if ($this->has_separate_unit_field()) {
1,113✔
1611
            $res["{$this->partindex}_{$this->numbox}"] = $this->postunit;
315✔
1612
        }
1613

1614
        if ($forfeedback) {
1,113✔
1615
            $res = $this->translate_mc_answers_for_feedback($res);
315✔
1616
        }
1617

1618
        return $res;
1,113✔
1619
    }
1620

1621
    /**
1622
     * When using multichoice options in a question (e.g. radio buttons or a dropdown list),
1623
     * the answer will be stored as a number, e.g. 1 for the *second* option. However, when
1624
     * giving feedback to the student, we want to show the actual *value* of the option and not
1625
     * its number. This function makes sure the stored answer is translated accordingly.
1626
     *
1627
     * @param array $response the response given by the student
1628
     * @return array updated response
1629
     */
1630
    public function translate_mc_answers_for_feedback(array $response): array {
1631
        // First, we fetch all answer boxes.
1632
        $boxes = self::scan_for_answer_boxes($this->subqtext);
336✔
1633

1634
        foreach ($boxes as $key => $box) {
336✔
1635
            // If it is not a multiple choice answer, we have nothing to do.
1636
            if ($box['options'] === '') {
168✔
1637
                continue;
84✔
1638
            }
1639

1640
            // Name of the array containing the choices.
1641
            $source = $box['options'];
84✔
1642

1643
            // Student's choice.
1644
            $userschoice = $response["{$this->partindex}$key"];
84✔
1645

1646
            // Fetch the value.
1647
            $parser = new parser("{$source}[$userschoice]");
84✔
1648
            try {
1649
                $result = $this->evaluator->evaluate($parser->get_statements()[0]);
84✔
1650
                $response["{$this->partindex}$key"] = $result->value;
84✔
1651
            } catch (Exception $e) {
21✔
1652
                // TODO: convert to non-capturing catch
1653
                // If there was an error, we leave the value as it is. This should
1654
                // not happen, because upon creation of the question, we check whether
1655
                // the variable containing the choices exists.
1656
                debugging('Could not translate multiple choice index back to its value. This should not happen. ' .
21✔
1657
                    'Please file a bug report.');
21✔
1658
            }
1659
        }
1660

1661
        return $response;
336✔
1662
    }
1663

1664

1665
    /**
1666
     * If the part is not correctly answered, we will set all answers to the empty string. Otherwise, we
1667
     * just return an empty array. This function will be called by the main question (for every part) and
1668
     * will be used to reset answers from wrong parts.
1669
     *
1670
     * @param array $response student's response
1671
     * @return array either an empty array (if part is correct) or an array with all answers being the empty string
1672
     */
1673
    public function clear_from_response_if_wrong(array $response): array {
1674
        // First, we have the response graded.
1675
        list('answer' => $answercorrect, 'unit' => $unitcorrect) = $this->grade($response);
42✔
1676

1677
        // If the part's answer is correct (including the unit, if any), we return an empty array.
1678
        // The caller of this function uses our values to overwrite the ones in the response, so
1679
        // that's fine.
1680
        if ($answercorrect >= 0.999 && $unitcorrect) {
42✔
1681
            return [];
21✔
1682
        }
1683

1684
        $result = [];
42✔
1685
        foreach (array_keys($this->get_expected_data()) as $key) {
42✔
1686
            $result[$key] = '';
42✔
1687
        }
1688
        return $result;
42✔
1689
    }
1690

1691

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