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

FormulasQuestion / moodle-qtype_formulas / 18965255339

31 Oct 2025 07:01AM UTC coverage: 97.54% (-0.03%) from 97.565%
18965255339

push

github

web-flow
follow updated Moodle coding style (#293)

618 of 625 new or added lines in 10 files covered. (98.88%)

4600 of 4716 relevant lines covered (97.54%)

2029.28 hits per line

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

97.93
/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
// TODO: rewrite input checker script for student answer and teacher's model answer / unit.
28

29
use qtype_formulas\local\evaluator;
30
use qtype_formulas\local\formulas_part;
31
use qtype_formulas\local\random_parser;
32
use qtype_formulas\local\parser;
33

34
defined('MOODLE_INTERNAL') || die();
35

NEW
36
require_once($CFG->dirroot . '/question/type/formulas/classes/local/formulas_part.php');
×
37
require_once($CFG->dirroot . '/question/type/formulas/questiontype.php');
×
38
require_once($CFG->dirroot . '/question/type/formulas/answer_unit.php');
×
39
require_once($CFG->dirroot . '/question/type/formulas/conversion_rules.php');
×
40
require_once($CFG->dirroot . '/question/behaviour/adaptivemultipart/behaviour.php');
×
41

42
// phpcs:disable moodle.Files.LineLength.TooLong
43

44
/**
45
 * Base class for the Formulas question type.
46
 *
47
 * @copyright 2010-2011 Hon Wai, Lau; 2023 Philipp Imhof
48
 * @author Hon Wai, Lau <lau65536@gmail.com>
49
 * @author Philipp Imhof
50
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
51
 */
52
class qtype_formulas_question extends question_graded_automatically_with_countback implements question_automatically_gradable_with_multiple_parts {
53
    // phpcs:enable moodle.Files.LineLength.TooLong
54
    /** @var int seed used to initialize the RNG; needed to restore an attempt state */
55
    public int $seed;
56

57
    /** @var ?evaluator evaluator class, this is where the evaluation stuff happens */
58
    public ?evaluator $evaluator = null;
59

60
    /** @var string $varsrandom definition text for random variables, as entered in the edit form */
61
    public string $varsrandom;
62

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

66
    /** @var formulas_part[] parts of the question */
67
    public $parts = [];
68

69
    /** @var string numbering (if any) of answers */
70
    public string $answernumbering;
71

72
    /** @var int number of parts in this question, used e.g. by the renderer */
73
    public int $numparts;
74

75
    /**
76
     * @var string[] strings (one more than $numparts) containing fragments from the question's main text
77
     *               that surround the parts' subtexts; used by the renderer
78
     */
79
    public array $textfragments;
80

81
    /** @var string $correctfeedback combined feedback for correct answer */
82
    public string $correctfeedback;
83

84
    /** @var int $correctfeedbackformat format of combined feedback for correct answer */
85
    public int $correctfeedbackformat;
86

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

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

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

96
    /** @var int $incorrectfeedbackformat format of combined feedback for incorrect answer */
97
    public int $incorrectfeedbackformat;
98

99
    /**
100
     * Create the appropriate behaviour for an attempt at this question.
101
     *
102
     * @param question_attempt $qa
103
     * @param string $preferredbehaviour
104
     * @return question_behaviour
105
     */
106
    public function make_behaviour(question_attempt $qa, $preferredbehaviour) {
107
        // If the requested behaviour is 'adaptive' or 'adaptiveopenpenalty', we have to change it
108
        // to 'adaptivemultipart'.
109
        if (in_array($preferredbehaviour, ['adaptive', 'adaptiveopenpenalty'])) {
4,008✔
110
            return question_engine::make_behaviour('adaptivemultipart', $qa, $preferredbehaviour);
360✔
111
        }
112

113
        // Otherwise, pass it on to the parent class.
114
        return parent::make_behaviour($qa, $preferredbehaviour);
3,672✔
115
    }
116

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

132
        // Create an empty evaluator, feed it with the random variables and instantiate
133
        // them.
134
        $this->evaluator = new evaluator();
4,656✔
135
        $randomparser = new random_parser($this->varsrandom);
4,656✔
136
        $this->evaluator->evaluate($randomparser->get_statements());
4,656✔
137
        $this->evaluator->instantiate_random_variables($this->seed);
4,656✔
138

139
        // Parse the definition of global variables and evaluate them, taking into account
140
        // the random variables.
141
        $globalparser = new parser($this->varsglobal, $randomparser->export_known_variables());
4,656✔
142
        $this->evaluator->evaluate($globalparser->get_statements());
4,656✔
143

144
        // For improved backwards-compatibility (allowing downgrade back to 5.x), we also store
145
        // the legacy qt vars '_randomsvars_text' (not a typo) and '_varsglobal' in the DB.
146
        $legacynote = "# Legacy entry for backwards compatibility only\r\n";
4,656✔
147
        $step->set_qt_var('_randomsvars_text', $legacynote . $this->evaluator->export_randomvars_for_step_data());
4,656✔
148
        $step->set_qt_var('_varsglobal', $legacynote . $this->varsglobal);
4,656✔
149

150
        // Set the question's $numparts property.
151
        $this->numparts = count($this->parts);
4,656✔
152

153
        // Finally, set up the parts' evaluators that evaluate the local variables.
154
        $this->initialize_part_evaluators();
4,656✔
155
    }
156

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

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

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

196
        // Set the question's $numparts property.
197
        $this->numparts = count($this->parts);
48✔
198

199
        // Set up the parts' evaluator classes and evaluate their local variables.
200
        $this->initialize_part_evaluators();
48✔
201

202
        parent::apply_attempt_state($step);
48✔
203
    }
204

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

217
        // For every part, we clone the current evaluator, so each part gets the same base of
218
        // instantiated random and global variables. Then we use the evaluator to prepare the part's
219
        // text.
220
        foreach ($this->parts as $part) {
4,080✔
221
            $subqtext = $part->evaluator->substitute_variables_in_text($part->subqtext);
4,080✔
222
            $chunk = $this->html_to_text($subqtext, $part->subqtextformat);
4,080✔
223
            // If the part has a placeholder, we insert the part's text at the position of the
224
            // placeholder. Otherwise, we simply append it.
225
            if ($part->placeholder !== '') {
4,080✔
226
                $summary = str_replace("{{$part->placeholder}}", $chunk, $summary);
240✔
227
            } else {
228
                $summary .= $chunk;
3,840✔
229
            }
230
        }
231

232
        // For the question summary, it seems useful to simplify the answer box placeholders.
233
        $summary = preg_replace(
4,080✔
234
            '/\{(_u|_\d+)(:(_[A-Za-z]|[A-Za-z]\w*)(:(MC|MCE|MCES|MCS))?)?((\|[\w =#]*)*)\}/',
4,080✔
235
            '{\1}',
4,080✔
236
            $summary,
4,080✔
237
        );
4,080✔
238

239
        return $summary;
4,080✔
240
    }
241

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

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

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

280
        return $response;
48✔
281
    }
282

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

295
        $numcorrect = 0;
552✔
296
        foreach ($this->parts as $part) {
552✔
297
            ['answer' => $answercorrect, 'unit' => $unitcorrect] = $part->grade($response);
552✔
298

299
            if ($answercorrect >= 0.999 && $unitcorrect == true) {
552✔
300
                $numcorrect++;
312✔
301
            }
302
        }
303
        return [$numcorrect, $this->numparts];
552✔
304
    }
305

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

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

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

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

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

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

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

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

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

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

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

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

442
        if ($component === 'question' && $filearea === 'hint') {
48✔
443
            return $this->check_hint_file_access($qa, $options, $args);
24✔
444
        }
445

446
        return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload);
24✔
447
    }
448

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

464
        // Still here? Then the question is not gradable.
465
        return false;
504✔
466
    }
467

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

483
        // Still here? Then all parts have been fully answered.
484
        return true;
1,656✔
485
    }
486

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

503
        // Still here? Then it's the same response.
504
        return true;
120✔
505
    }
506

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

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

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

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

542
            // If there is an answer, we check its correctness.
543
            ['answer' => $answergrade, 'unit' => $unitcorrect] = $part->grade($response);
456✔
544

545
            if ($part->postunit !== '') {
456✔
546
                // The unit can only be correct (1.0) or wrong (0.0).
547
                // The answer can be any float from 0.0 to 1.0 inclusive.
548
                if ($answergrade >= 0.999 && $unitcorrect) {
240✔
549
                    $classification[$part->partindex] = new question_classified_response(
48✔
550
                        'right',
48✔
551
                        $part->summarise_response($response),
48✔
552
                        1,
48✔
553
                    );
48✔
554
                } else if ($unitcorrect) {
192✔
555
                    $classification[$part->partindex] = new question_classified_response(
48✔
556
                        'wrongvalue',
48✔
557
                        $part->summarise_response($response),
48✔
558
                        0,
48✔
559
                    );
48✔
560
                } else if ($answergrade >= 0.999) {
144✔
561
                    $classification[$part->partindex] = new question_classified_response(
48✔
562
                        'wrongunit',
48✔
563
                        $part->summarise_response($response),
48✔
564
                        1 - $part->unitpenalty,
48✔
565
                    );
48✔
566
                } else {
567
                    $classification[$part->partindex] = new question_classified_response(
150✔
568
                        'wrong',
150✔
569
                        $part->summarise_response($response),
150✔
570
                        0,
150✔
571
                    );
150✔
572
                }
573
            } else {
574
                if ($answergrade >= .999) {
216✔
575
                    $classification[$part->partindex] = new question_classified_response(
144✔
576
                        'right',
144✔
577
                        $part->summarise_response($response),
144✔
578
                        $answergrade,
144✔
579
                    );
144✔
580
                } else {
581
                     $classification[$part->partindex] = new question_classified_response(
144✔
582
                         'wrong',
144✔
583
                         $part->summarise_response($response),
144✔
584
                         $answergrade,
144✔
585
                     );
144✔
586
                }
587
            }
588
        }
589
        return $classification;
552✔
590
    }
591

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

607
        // If at least one part is gradable and yet the question is in "invalid" state, that means
608
        // that the behaviour expected all fields to be filled.
609
        return get_string('pleaseputananswer', 'qtype_formulas');
120✔
610
    }
611

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

624
        $totalpossible = 0;
1,464✔
625
        $achievedmarks = 0;
1,464✔
626
        // Separately grade each part.
627
        foreach ($this->parts as $part) {
1,464✔
628
            // Count the total number of points for this part.
629
            $totalpossible += $part->answermark;
1,464✔
630

631
            $partsgrade = $part->grade($response);
1,464✔
632
            $fraction = $partsgrade['answer'];
1,464✔
633
            // If unit is wrong, make the necessary deduction.
634
            if ($partsgrade['unit'] === false) {
1,464✔
635
                $fraction = $fraction * (1 - $part->unitpenalty);
360✔
636
            }
637

638
            // Add the number of points achieved to the total.
639
            $achievedmarks += $part->answermark * $fraction;
1,464✔
640
        }
641

642
        // Finally, calculate the overall fraction of points received vs. possible points
643
        // and return the fraction together with the correct question state (i. e. correct,
644
        // partiall correct or wrong).
645
        $fraction = $achievedmarks / $totalpossible;
1,464✔
646
        return [$fraction, question_state::graded_state_for_fraction($fraction)];
1,464✔
647
    }
648

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

665
        foreach ($this->parts as $part) {
552✔
666
            // Check whether we already have an attempt for this part. If we don't, we create an
667
            // empty response.
668
            $lastresponse = [];
552✔
669
            if (array_key_exists($part->partindex, $lastgradedresponses)) {
552✔
670
                $lastresponse = $lastgradedresponses[$part->partindex];
360✔
671
            }
672

673
            // Check whether the response has been changed since the last attempt. If it has not,
674
            // we are done for this part.
675
            if ($part->is_same_response($lastresponse, $response)) {
552✔
676
                continue;
240✔
677
            }
678

679
            $partsgrade = $part->grade($response);
528✔
680
            $fraction = $partsgrade['answer'];
528✔
681
            // If unit is wrong, make the necessary deduction.
682
            if ($partsgrade['unit'] === false) {
528✔
683
                $fraction = $fraction * (1 - $part->unitpenalty);
72✔
684
            }
685

686
            $partresults[$part->partindex] = new qbehaviour_adaptivemultipart_part_result(
528✔
687
                $part->partindex,
528✔
688
                $fraction,
528✔
689
                $this->penalty,
528✔
690
            );
528✔
691
        }
692

693
        return $partresults;
552✔
694
    }
695

696
    /**
697
     * Get a list of all the parts of the question and the weight they have within
698
     * the question.
699
     *
700
     * @return array part identifier => weight
701
     */
702
    public function get_parts_and_weights() {
703
        // First, we calculate the sum of all marks.
704
        $sum = 0;
432✔
705
        foreach ($this->parts as $part) {
432✔
706
            $sum += $part->answermark;
432✔
707
        }
708

709
        // Now that the total is known, we calculate each part's weight.
710
        $weights = [];
432✔
711
        foreach ($this->parts as $part) {
432✔
712
            $weights[$part->partindex] = $part->answermark / $sum;
432✔
713
        }
714

715
        return $weights;
432✔
716
    }
717

718
    /**
719
     * Check whether two responses for a given part (and only for that part) are identical.
720
     * This is used when working with multiple tries in order to avoid getting a penalty
721
     * deduction for an unchanged wrong answer that has already been counted before.
722
     *
723
     * @param string $id part indentifier
724
     * @param array $prevresponse previously recorded responses (for entire question)
725
     * @param array $newresponse new responses (for entire question)
726
     * @return bool
727
     */
728
    public function is_same_response_for_part($id, array $prevresponse, array $newresponse): bool {
729
        return $this->parts[$id]->is_same_response($prevresponse, $newresponse);
72✔
730
    }
731

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

751
        return true;
168✔
752
    }
753

754
    /**
755
     * Work out a final grade for this attempt, taking into account all the tries the student made.
756
     * This method is called in interactive mode when all tries are done or when the user hits
757
     * 'Submit and finish'.
758
     *
759
     * @param array $responses response for each try, each element (1 <= n <= $totaltries) is a response array
760
     * @param int $totaltries maximum number of tries allowed
761
     * @return float grade that should be awarded for this sequence of responses
762
     */
763
    public function compute_final_grade($responses, $totaltries): float {
764
        $obtainedgrade = 0;
216✔
765
        $maxgrade = 0;
216✔
766

767
        foreach ($this->parts as $part) {
216✔
768
            $maxgrade += $part->answermark;
216✔
769

770
            // We start with an empty last response.
771
            $lastresponse = [];
216✔
772
            $lastchange = 0;
216✔
773

774
            $partfraction = 0;
216✔
775

776
            foreach ($responses as $responseindex => $response) {
216✔
777
                // If the response has not changed, we have nothing to do.
778
                if ($part->is_same_response($lastresponse, $response)) {
216✔
779
                    continue;
72✔
780
                }
781

782
                $response = $this->normalize_response($response);
216✔
783

784
                // Otherwise, save this as the last response and store the index where
785
                // the response was changed for the last time.
786
                $lastresponse = $response;
216✔
787
                $lastchange = $responseindex;
216✔
788

789
                // Obtain the grade for the current response.
790
                $partgrade = $part->grade($response);
216✔
791

792
                $partfraction = $partgrade['answer'];
216✔
793
                // If unit is wrong, make the necessary deduction.
794
                if ($partgrade['unit'] === false) {
216✔
795
                    $partfraction = $partfraction * (1 - $part->unitpenalty);
96✔
796
                }
797
            }
798
            $obtainedgrade += $part->answermark * max(0, $partfraction - $lastchange * $this->penalty);
216✔
799
        }
800

801
        return $obtainedgrade / $maxgrade;
216✔
802
    }
803

804
    /**
805
     * Set up an evaluator class for every part and have it evaluate the local variables.
806
     *
807
     * @return void
808
     */
809
    public function initialize_part_evaluators(): void {
810
        // For every part, we clone the question's evaluator in order to have the
811
        // same set of (instantiated) random and global variables.
812
        foreach ($this->parts as $part) {
4,656✔
813
            $part->evaluator = clone $this->evaluator;
4,656✔
814

815
            // Parse and evaluate the local variables, if there are any. We do not need to
816
            // retrieve or store the result, because the vars will be set inside the evaluator.
817
            if (!empty($part->vars1)) {
4,656✔
818
                $parser = new parser($part->vars1);
96✔
819
                $part->evaluator->evaluate($parser->get_statements());
96✔
820
            }
821

822
            // Parse, evaluate and store the model answers. They will be returned as tokens,
823
            // so we need to "unpack" them. We always store the model answers as an array; if
824
            // there is only one answer, we wrap the value into an array.
825
            $part->get_evaluated_answers();
4,656✔
826
        }
827
    }
828

829
    /**
830
     * Normalize student response for each part, i. e. split number and unit for combined answer
831
     * fields, trim answers and set missing answers to empty string to make sure all expected
832
     * response fields are set.
833
     *
834
     * @param array $response the student's response
835
     * @return array normalized response
836
     */
837
    public function normalize_response(array $response): array {
838
        $result = [];
1,776✔
839

840
        // Normalize the responses for each part.
841
        foreach ($this->parts as $part) {
1,776✔
842
            $result += $part->normalize_response($response);
1,776✔
843
        }
844

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

849
        return $result;
1,776✔
850
    }
851
}
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