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

FormulasQuestion / moodle-qtype_formulas / 13217446514

08 Feb 2025 04:37PM UTC coverage: 76.899% (+1.9%) from 75.045%
13217446514

Pull #62

github

web-flow
Merge b36f9931f into acd272945
Pull Request #62: Rewrite the parser

2547 of 3139 new or added lines in 22 files covered. (81.14%)

146 existing lines in 6 files now uncovered.

3006 of 3909 relevant lines covered (76.9%)

438.31 hits per line

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

94.63
/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

UNCOV
43
require_once($CFG->dirroot . '/question/type/formulas/questiontype.php');
×
UNCOV
44
require_once($CFG->dirroot . '/question/type/formulas/answer_unit.php');
×
UNCOV
45
require_once($CFG->dirroot . '/question/type/formulas/conversion_rules.php');
×
UNCOV
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'])) {
816✔
115
            return question_engine::make_behaviour('adaptivemultipart', $qa, $preferredbehaviour);
204✔
116
        }
117

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

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

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

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

162
    /**
163
     * When reloading an in-progress {@link 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 {@link 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();
34✔
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')) {
34✔
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');
34✔
181
            $parser = new random_parser($this->varsrandom);
34✔
182
            $this->evaluator->evaluate($parser->get_statements());
34✔
183
            $this->evaluator->instantiate_random_variables($this->seed);
34✔
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());
34✔
188
            $this->evaluator->evaluate($globalparser->get_statements());
34✔
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');
17✔
196
            $this->varsglobal = $step->get_qt_var('_varsglobal');
17✔
197
            $parser = new parser($randominstantiated . $this->varsglobal);
17✔
198
            $this->evaluator->evaluate($parser->get_statements());
17✔
199
        }
200

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

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

207
        parent::apply_attempt_state($step);
34✔
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);
867✔
220
        $summary = $this->html_to_text($questiontext, $this->questiontextformat);
867✔
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) {
867✔
226
            $subqtext = $part->evaluator->substitute_variables_in_text($part->subqtext);
867✔
227
            $chunk = $this->html_to_text($subqtext, $part->subqtextformat);
867✔
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 !== '') {
867✔
231
                $summary = str_replace("{{$part->placeholder}}", $chunk, $summary);
102✔
232
            } else {
233
                $summary .= $chunk;
765✔
234
            }
235
        }
236
        return $summary;
867✔
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) {
34✔
250
            return PHP_INT_MAX;
17✔
251
        }
252
        return $this->evaluator->get_number_of_variants();
34✔
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.
NEW
273
        foreach ($this->parts as $part) {
×
NEW
274
            $response = $part->clear_from_response_if_wrong($response) + $response;
×
275
        }
276

NEW
277
        return $response;
×
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);
238✔
291

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

296
            if ($answercorrect >= 0.999 && $unitcorrect == true) {
238✔
297
                $numcorrect++;
136✔
298
            }
299
        }
300
        return [$numcorrect, $this->numparts];
238✔
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 = [];
391✔
313
        foreach ($this->parts as $part) {
391✔
314
            $expected += $part->get_expected_data();
391✔
315
        }
316
        return $expected;
391✔
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 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)) {
884✔
329
            return $part->get_correct_response();
51✔
330
        }
331

332
        // Otherwise, fetch them all.
333
        $responses = [];
884✔
334
        foreach ($this->parts as $part) {
884✔
335
            $responses += $part->get_correct_response();
884✔
336
        }
337
        return $responses;
884✔
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) {
323✔
356
            $text = $this->evaluator->substitute_variables_in_text($text);
238✔
357
        }
358
        return parent::format_text($text, $format, $qa, $component, $filearea, $itemid, $clean);
323✔
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])) {
85✔
376
            return false;
17✔
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];
85✔
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') {
85✔
384
            foreach ($this->parts as $part) {
17✔
385
                if ($part->id == $itemid) {
17✔
386
                    return true;
17✔
387
                }
388
            }
389
            // If we did not find a matching part, we don't serve the file.
390
            return false;
17✔
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'];
85✔
395
        if ($component === 'qtype_formulas' && in_array($filearea, $ownfeedbackareas)) {
85✔
396
            // If the $itemid does not belong to our parts, we can leave.
397
            $validpart = false;
51✔
398
            foreach ($this->parts as $part) {
51✔
399
                if ($part->id == $itemid) {
51✔
400
                    $validpart = true;
51✔
401
                    break;
51✔
402
                }
403
            }
404
            if (!$validpart) {
51✔
405
                return false;
51✔
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();
51✔
411
            if (!$state->is_finished()) {
51✔
412
                $response = $qa->get_last_qt_data();
51✔
413
                if (!$this->is_gradable_response($response)) {
51✔
414
                    return false;
51✔
415
                }
416
                // Response is gradable, so try to grade and get the corresponding state.
417
                list($ignored, $state) = $this->grade_response($response);
17✔
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') {
51✔
423
                return $options->generalfeedback;
17✔
424
            }
425

426
            // Fetching the feedback class, i. e. 'correct' or 'partiallycorrect' or 'incorrect'.
427
            $feedbackclass = $state->get_feedback_class();
34✔
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");
34✔
432
        }
433

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

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

443
        return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload);
17✔
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 {@link 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 one is not gradable, we return early.
455
        foreach ($this->parts as $part) {
323✔
456
            if (!$part->is_gradable_response($response)) {
323✔
457
                return false;
272✔
458
            }
459
        }
460

461
        // Still here? Then the question is gradable.
462
        return true;
272✔
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 {@link 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,071✔
475
            if (!$part->is_complete_response($response)) {
1,071✔
476
                return false;
340✔
477
            }
478
        }
479

480
        // Still here? Then all parts have been fully answered.
481
        return true;
799✔
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 {@link 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) {
204✔
495
            if (!$part->is_same_response($prevresponse, $newresponse)) {
204✔
496
                return false;
204✔
497
            }
498
        }
499

500
        // Still here? Then it's the same response.
501
        return true;
68✔
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 {@link grade_response()}
508
     * @return string plain text summary
509
     */
510
    public function summarise_response(array $response) {
511
        $summary = [];
850✔
512

513
        // Summarise each part's answers.
514
        foreach ($this->parts as $part) {
850✔
515
            $summary[] = $part->summarise_response($response);
850✔
516
        }
517
        return implode(', ', $summary);
850✔
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 {@link grade_response()}
524
     * @return array subpartid => {@link 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);
391✔
529

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

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

542
            if ($part->postunit !== '') {
323✔
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) {
170✔
546
                    $classification[$part->partindex] = new question_classified_response(
34✔
547
                            'right', $part->summarise_response($response), 1);
34✔
548
                } else if ($unitcorrect) {
136✔
549
                    $classification[$part->partindex] = new question_classified_response(
34✔
550
                            'wrongvalue', $part->summarise_response($response), 0);
34✔
551
                } else if ($answergrade >= 0.999) {
102✔
552
                    $classification[$part->partindex] = new question_classified_response(
34✔
553
                            'wrongunit', $part->summarise_response($response), 1 - $part->unitpenalty);
34✔
554
                } else {
555
                    $classification[$part->partindex] = new question_classified_response(
122✔
556
                            'wrong', $part->summarise_response($response), 0);
122✔
557
                }
558
            } else {
559
                if ($answergrade >= .999) {
153✔
560
                    $classification[$part->partindex] = new question_classified_response(
102✔
561
                            'right', $part->summarise_response($response), $answergrade);
102✔
562
                } else {
563
                     $classification[$part->partindex] = new question_classified_response(
102✔
564
                            'wrong', $part->summarise_response($response), $answergrade);
102✔
565
                }
566
            }
567
        }
568
        return $classification;
391✔
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)) {
51✔
583
            return get_string('allfieldsempty', 'qtype_formulas');
51✔
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.
NEW
588
        return get_string('pleaseputananswer', 'qtype_formulas');
×
589
    }
590

591
    /**
592
     * Grade a response to the question, returning a fraction between get_min_fraction()
593
     * and 1.0, and the corresponding {@link 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 {@link 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);
663✔
602

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

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

617
            // Add the number of points achieved to the total.
618
            $achievedmarks += $part->answermark * $fraction;
663✔
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;
663✔
625
        return [$fraction, question_state::graded_state_for_fraction($fraction)];
663✔
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 = [];
340✔
643

644
        foreach ($this->parts as $part) {
340✔
645
            // Check whether we already have an attempt for this part. If we don't, we create an
646
            // empty response.
647
            $lastresponse = [];
340✔
648
            if (array_key_exists($part->partindex, $lastgradedresponses)) {
340✔
649
                $lastresponse = $lastgradedresponses[$part->partindex];
238✔
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)) {
340✔
655
                continue;
153✔
656
            }
657

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

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

670
        return $partresults;
340✔
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;
255✔
682
        foreach ($this->parts as $part) {
255✔
683
            $sum += $part->answermark;
255✔
684
        }
685

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

692
        return $weights;
255✔
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);
51✔
707
    }
708

709
    /**
710
     * This is called by adaptive multiplart 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) {
204✔
723
            if ($part->is_gradable_response($response)) {
204✔
724
                return false;
204✔
725
            }
726
        }
727

728
        return true;
51✔
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;
119✔
742
        $maxgrade = 0;
119✔
743

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

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

751
            $partfraction = 0;
119✔
752

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

759
                $response = $this->normalize_response($response);
119✔
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;
119✔
764
                $lastchange = $responseindex;
119✔
765

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

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

778
        return $obtainedgrade / $maxgrade;
119✔
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,224✔
790
            $part->evaluator = clone $this->evaluator;
1,224✔
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,224✔
NEW
795
                $parser = new parser($part->vars1);
×
NEW
796
                $part->evaluator->evaluate($parser->get_statements());
×
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,224✔
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 = [];
884✔
816

817
        // Normalize the responses for each part.
818
        foreach ($this->parts as $part) {
884✔
819
            $result += $part->normalize_response($response);
884✔
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;
884✔
825

826
        return $result;
884✔
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
    }
1,598✔
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 !== '';
1,462✔
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) {
1,462✔
949
            return false;
1,003✔
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;
527✔
956
        $noplaceholders = strpos($this->subqtext, '{_0}') === false && strpos($this->subqtext, '{_u}') === false;
527✔
957
        return $combinedrequested || $noplaceholders;
527✔
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,054✔
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) {
476✔
980
            if (!question_utils::arrays_same_at_key_missing_is_blank($prevresponse, $newresponse, $key)) {
476✔
981
                return false;
459✔
982
            }
983
        }
984

985
        // Still here? That means they are all the same.
986
        return true;
221✔
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 {@link 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,037✔
1000
            return ["{$this->partindex}_" => PARAM_RAW];
272✔
1001
        }
1002

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

1009
        // If there is a separate unit field, we add it to the list.
1010
        if ($this->has_separate_unit_field()) {
799✔
1011
            $expected["{$this->partindex}_{$this->numbox}"] = PARAM_RAW;
187✔
1012
        }
1013
        return $expected;
799✔
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);
816✔
1037

1038
        $boxes = [];
816✔
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) {
816✔
1042
            // Duplicates are not allowed.
1043
            if (array_key_exists($match, $boxes)) {
510✔
1044
                throw new Exception(get_string('error_answerbox_duplicate', 'qtype_formulas', $match));
102✔
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] = [
510✔
1054
                'placeholder' => $matches[0][$i],
510✔
1055
                'options' => $matches[3][$i],
510✔
1056
                'dropdown' => ($matches[4][$i] === ':MCE'),
510✔
1057
            ];
510✔
1058
        }
1059
        return $boxes;
714✔
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 {@link 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 = [];
850✔
1070

1071
        $isnormalized = array_key_exists('normalized', $response);
850✔
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()) {
850✔
1076
            // If the answer is normalized, the combined field has already been split, so we
1077
            // recombine both parts.
1078
            if ($isnormalized) {
323✔
1079
                return trim($response["{$this->partindex}_0"] . " " . $response["{$this->partindex}_1"]);
68✔
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}_"])) {
323✔
1085
                return $response["{$this->partindex}_"];
323✔
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) {
561✔
1092
            if (array_key_exists($key, $response)) {
561✔
1093
                $summary[] = $response[$key];
544✔
1094
            }
1095
        }
1096

1097
        // Transform the array to a comma-separated list for a nice summary.
1098
        return implode(', ', $summary);
561✔
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,003✔
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,003✔
1116
        if (isset($response[$name])) {
1,003✔
1117
            $combined = trim($response[$name]);
340✔
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);
340✔
1125
                $splitindex = $parser->find_start_of_units();
340✔
NEW
1126
            } catch (Throwable $t) {
×
1127
                // TODO: convert to non-capturing catch.
NEW
1128
                $splitindex = PHP_INT_MAX;
×
1129
            }
1130

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

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

1139
        // Otherwise, we iterate from 0 to numbox, inclusive (if there is a unit field) or exclusive.
1140
        $count = $this->numbox;
986✔
1141
        if ($this->has_unit()) {
986✔
1142
            $count++;
459✔
1143
        }
1144
        for ($i = 0; $i < $count; $i++) {
986✔
1145
            $name = "{$this->partindex}_$i";
986✔
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])) {
986✔
1150
                $result[$name] = trim($response[$name]);
986✔
1151
            } else {
1152
                $result[$name] = '';
323✔
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) {
986✔
1159
                $result[$name] = substr($result[$name], 0, 128);
17✔
1160
            }
1161
        }
1162

1163
        return $result;
986✔
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);
323✔
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,071✔
1191
            return !empty($response["{$this->partindex}_"]) && strlen($response["{$this->partindex}_"]) > 0;
306✔
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++) {
765✔
1197
            if (!isset($response["{$this->partindex}_{$i}"]) || strlen($response["{$this->partindex}_{$i}"]) == 0) {
765✔
1198
                return false;
272✔
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()) {
629✔
1205
            return !empty($response["{$this->partindex}_{$this->numbox}"])
102✔
1206
                && strlen($response["{$this->partindex}_{$this->numbox}"]) > 0;
102✔
1207
        }
1208

1209
        // Still here? That means no expected field was missing and no fields were empty.
1210
        return true;
527✔
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
        $isnormalized = array_key_exists('normalized', $response);
714✔
1221

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

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

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

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

1281
        // Still here? Then let's evaluate the answers.
1282
        $result = [];
1,224✔
1283

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

1287
        $parser = new parser($this->answer, $this->evaluator->export_variable_list());
1,224✔
1288
        $result = $this->evaluator->evaluate($parser->get_statements())[0];
1,224✔
1289

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

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

1312
        return $this->evaluatedanswers;
1,224✔
1313
    }
1314

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

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

1336
        return $formulas;
136✔
1337
    }
1338

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

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

1356
        return false;
17✔
1357
    }
1358

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1558
        return ['answer' => $evaluatedgrading, 'unit' => $unitcorrect];
901✔
1559
    }
1560

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

1575
        $checked = $checkunit->check_convertibility($studentsunit, $this->postunit);
935✔
1576
        if ($checked->convertible) {
935✔
1577
            return $checked->cfactor;
714✔
1578
        }
1579

1580
        return false;
357✔
1581
    }
1582

1583
    /**
1584
     * Return an array containing the correct answers for this question part. If $forfeedback
1585
     * is set to true, multiple choice answers are translated from their list index to their
1586
     * value (e.g. the text) to provide feedback to the student. Also, quotes are stripped
1587
     * from algebraic formulas. Otherwise, the function returns the values as they are needed
1588
     * to obtain full mark at this question.
1589
     *
1590
     * @param bool $forfeedback whether we request correct answers for student feedback
1591
     * @return array list of correct answers
1592
     */
1593
    public function get_correct_response(bool $forfeedback = false): array {
1594
        // Fetch the evaluated answers.
1595
        $answers = $this->get_evaluated_answers();
884✔
1596

1597
        // If we have a combined unit field, we return both the model answer plus the unit
1598
        // in "i_". Combined fields are only possible for parts with one signle answer.
1599
        if ($this->has_combined_unit_field()) {
884✔
1600
            return ["{$this->partindex}_" => trim($answers[0] . ' ' . $this->postunit)];
323✔
1601
        }
1602

1603
        // Strip quotes around algebraic formulas, if the answers are used for feedback.
1604
        if ($forfeedback && $this->answertype === qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
578✔
NEW
1605
            $answers = str_replace('"', '', $answers);
×
1606
        }
1607

1608
        // Otherwise, we build an array with all answers, according to our naming scheme.
1609
        $res = [];
578✔
1610
        for ($i = 0; $i < $this->numbox; $i++) {
578✔
1611
            $res["{$this->partindex}_{$i}"] = $answers[$i];
578✔
1612
        }
1613

1614
        // Finally, if we have a separate unit field, we add this as well.
1615
        if ($this->has_separate_unit_field()) {
578✔
1616
            $res["{$this->partindex}_{$this->numbox}"] = $this->postunit;
153✔
1617
        }
1618

1619
        if ($forfeedback) {
578✔
1620
            $res = $this->translate_mc_answers_for_feedback($res);
68✔
1621
        }
1622

1623
        return $res;
578✔
1624
    }
1625

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

1639
        foreach ($boxes as $key => $box) {
68✔
1640
            // If it is not a multiple choice answer, we have nothing to do.
1641
            if ($box['options'] === '') {
34✔
1642
                continue;
17✔
1643
            }
1644

1645
            // Name of the array containing the choices.
1646
            $source = $box['options'];
17✔
1647

1648
            // Student's choice.
1649
            $userschoice = $response["{$this->partindex}$key"];
17✔
1650

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

1666
        return $response;
68✔
1667
    }
1668

1669

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

1682
        // If the part's answer is correct (including the unit, if any), we return an empty array.
1683
        // The caller of this function uses our values to overwrite the ones in the response, so
1684
        // that's fine.
NEW
1685
        if ($answercorrect >= 0.999 && $unitcorrect) {
×
NEW
1686
            return [];
×
1687
        }
1688

NEW
1689
        $result = [];
×
NEW
1690
        foreach (array_keys($this->get_expected_data()) as $key) {
×
NEW
1691
            $result[$key] = '';
×
1692
        }
NEW
1693
        return $result;
×
1694
    }
1695
}
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

© 2025 Coveralls, Inc