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

FormulasQuestion / moodle-qtype_formulas / 14473980547

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

Pull #181

github

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

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

3916 of 4084 relevant lines covered (95.89%)

1564.19 hits per line

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

94.68
/questiontype.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 type 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
use qtype_formulas\answer_unit_conversion;
28
use qtype_formulas\unit_conversion_rules;
29
use qtype_formulas\local\evaluator;
30
use qtype_formulas\local\random_parser;
31
use qtype_formulas\local\answer_parser;
32
use qtype_formulas\local\parser;
33
use qtype_formulas\local\token;
34
use qtype_formulas\local\unit_parser;
35

36
defined('MOODLE_INTERNAL') || die();
37

38
require_once($CFG->libdir . '/questionlib.php');
×
39
require_once($CFG->dirroot . '/question/engine/lib.php');
×
40
require_once($CFG->dirroot . '/question/type/formulas/answer_unit.php');
×
41
require_once($CFG->dirroot . '/question/type/formulas/conversion_rules.php');
×
42
require_once($CFG->dirroot . '/question/type/formulas/question.php');
×
43

44
/**
45
 * Question type class for the Formulas question type.
46
 *
47
 * @copyright 2010-2011 Hon Wai, Lau; 2023 Philipp Imhof
48
 * @license https://www.gnu.org/copyleft/gpl.html GNU Public License version 3
49
 */
50
class qtype_formulas extends question_type {
51

52
    /** @var int */
53
    const ANSWER_TYPE_NUMBER = 0;
54

55
    /** @var int */
56
    const ANSWER_TYPE_NUMERIC = 10;
57

58
    /** @var int */
59
    const ANSWER_TYPE_NUMERICAL_FORMULA = 100;
60

61
    /** @var int */
62
    const ANSWER_TYPE_ALGEBRAIC = 1000;
63

64
    /**
65
     * The following array contains some of the column names of the table qtype_formulas_answers,
66
     * the table that holds the parts (not just answers) of a question. These columns undergo similar
67
     * validation, so they are grouped in this array. Some columns are not listed here, namely
68
     * the texts (part's text, part's feedback) and their formatting option, because they are
69
     * validated separately:
70
     * - placeholder: the part's placeholder to be used in the main question text, e. g. #1
71
     * - answermark: grade awarded for this part, if answer is fully correct
72
     * - numbox: number of answers for this part, not including a possible unit field
73
     * - vars1: the part's local variables
74
     * - answer: the model answer(s) for this part
75
     * - vars2: the part's grading variables
76
     * - correctness: the part's grading criterion
77
     * - unitpenalty: deduction to be made for wrong units
78
     * - postunit: the unit in which the model answer has been entered
79
     * - ruleid: ruleset used for unit conversion
80
     * - otherrule: additional rules for unit conversion
81
     */
82
    const PART_BASIC_FIELDS = ['placeholder', 'answermark', 'answertype', 'numbox', 'vars1', 'answer', 'answernotunique', 'vars2',
83
        'correctness', 'unitpenalty', 'postunit', 'ruleid', 'otherrule'];
84

85
    /**
86
     * This function returns the "simple" additional fields defined in the qtype_formulas_options
87
     * table. It is called by Moodle's core in order to have those fields automatically saved
88
     * backed up and restored. The basic fields like id and questionid do not need to included.
89
     * Also, we do not include the more "complex" feedback fields (correct, partially correct, incorrect),
90
     * as they need special treatment, because they can contain references to uploaded files.
91
     *
92
     * @return string[]
93
     */
94
    public function extra_question_fields() {
95
        return ['qtype_formulas_options', 'varsrandom', 'varsglobal', 'shownumcorrect', 'answernumbering'];
2,085✔
96
    }
97

98
    /**
99
     * Fetch the ID for every part of a given question.
100
     *
101
     * @param int $questionid
102
     * @return int[]
103
     */
104
    protected function fetch_part_ids_for_question(int $questionid): array {
105
        global $DB;
106

107
        // Fetch the parts from the DB. The result will be an associative array with
108
        // the parts' IDs as keys.
109
        $parts = $DB->get_records('qtype_formulas_answers', ['questionid' => $questionid]);
525✔
110

111
        return array_keys($parts);
525✔
112
    }
113

114
    /**
115
     * Move all the files belonging to this question (and its parts) from one context to another.
116
     *
117
     * @param int $questionid the question being moved.
118
     * @param int $oldcontextid the context it is moving from.
119
     * @param int $newcontextid the context it is moving to.
120
     */
121
    public function move_files($questionid, $oldcontextid, $newcontextid): void {
122
        // Fetch the part IDs for every part of this question.
123
        $partids = $this->fetch_part_ids_for_question($questionid);
105✔
124

125
        // Move files for all areas and all parts.
126
        $fs = get_file_storage();
105✔
127
        $areas = ['answersubqtext', 'answerfeedback', 'partcorrectfb', 'partpartiallycorrectfb', 'partincorrectfb'];
105✔
128
        foreach ($areas as $area) {
105✔
129
            $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'qtype_formulas', $area, $questionid);
105✔
130
            foreach ($partids as $partid) {
105✔
131
                $fs->move_area_files_to_new_context($oldcontextid, $newcontextid, 'qtype_formulas', $area, $partid);
105✔
132
            }
133
        }
134

135
        $this->move_files_in_combined_feedback($questionid, $oldcontextid, $newcontextid);
105✔
136
        $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
105✔
137

138
        // The parent method will move files from the question text and the general feedback.
139
        parent::move_files($questionid, $oldcontextid, $newcontextid);
105✔
140
    }
141

142
    /**
143
     * Delete all the files belonging to this question (and its parts).
144
     *
145
     * @param int $questionid the question being deleted.
146
     * @param int $contextid the context the question is in.
147
     */
148
    protected function delete_files($questionid, $contextid): void {
149
        // Fetch the part IDs for every part of this question.
150
        $partids = $this->fetch_part_ids_for_question($questionid);
504✔
151

152
        // Delete files for all areas and all parts.
153
        $fs = get_file_storage();
504✔
154
        $areas = ['answersubqtext', 'answerfeedback', 'partcorrectfb', 'partpartiallycorrectfb', 'partincorrectfb'];
504✔
155
        foreach ($areas as $area) {
504✔
156
            $fs->delete_area_files($contextid, 'qtype_formulas', $area, $questionid);
504✔
157
            foreach ($partids as $partid) {
504✔
158
                $fs->delete_area_files($contextid, 'qtype_formulas', $area, $partid);
504✔
159
            }
160
        }
161

162
        $this->delete_files_in_combined_feedback($questionid, $contextid);
504✔
163
        $this->delete_files_in_hints($questionid, $contextid);
504✔
164

165
        // The parent method will delete files from the question text and the general feedback.
166
        parent::delete_files($questionid, $contextid);
504✔
167
    }
168

169
    /**
170
     * Loads the question type specific options for the question.
171
     * $question already contains the question's general data from the question table when
172
     * this function is called.
173
     *
174
     * This function loads any question type specific options for the
175
     * question from the database into the question object. This information
176
     * is placed in the $question->options field. A question type is
177
     * free, however, to decide on a internal structure of the options field.
178
     * @param object $question The question object for the question. This object
179
     *                         should be updated to include the question type
180
     *                         specific information (it is passed by reference).
181
     * @return bool            Indicates success or failure.
182
     */
183
    public function get_question_options($question): bool {
184
        global $DB;
185

186
        // Fetch options from the table qtype_formulas_options. The DB engine will automatically
187
        // return a standard class where the attribute names match the column names.
188
        $question->options = $DB->get_record('qtype_formulas_options', ['questionid' => $question->id]);
1,434✔
189

190
        // In case of a DB error (e. g. missing record), get_record() returns false. In that case, we
191
        // create default options.
192
        if ($question->options === false) {
1,434✔
193
            debugging(get_string('error_db_missing_options', 'qtype_formulas', $question->id), DEBUG_DEVELOPER);
21✔
194
            $question->options = (object)[
21✔
195
                'questionid' => $question->id,
21✔
196
                'varsrandom' => '',
21✔
197
                'varsglobal' => '',
21✔
198
                'correctfeedback' => get_string('correctfeedbackdefault', 'question'),
21✔
199
                'correctfeedbackformat' => FORMAT_HTML,
21✔
200
                'partiallycorrectfeedback' => get_string('partiallycorrectfeedbackdefault', 'question'),
21✔
201
                'partiallycorrectfeedbackformat' => FORMAT_HTML,
21✔
202
                'incorrectfeedback' => get_string('incorrectfeedbackdefault', 'question'),
21✔
203
                'incorrectfeedbackformat' => FORMAT_HTML,
21✔
204
                'shownumcorrect' => 0,
21✔
205
                'answernumbering' => 'none',
21✔
206
            ];
21✔
207
        }
208

209
        parent::get_question_options($question);
1,434✔
210

211
        // Fetch parts' data and remove existing array indices (starting from first part's id) in order
212
        // to have the array indices start from 0.
213
        $question->options->answers = $DB->get_records('qtype_formulas_answers', ['questionid' => $question->id], 'partindex ASC');
1,434✔
214
        $question->options->answers = array_values($question->options->answers);
1,434✔
215

216
        // Correctly set the number of parts for this question.
217
        $question->options->numparts = count($question->options->answers);
1,434✔
218

219
        return true;
1,434✔
220
    }
221

222
    /**
223
     * Helper function to save files that are embedded in e. g. part's text or
224
     * feedback, avoids to set 'qtype_formulas' for every invocation.
225
     *
226
     * @param array $array the data from the form (or from import). This will
227
     *      normally have come from the formslib editor element, so it will be an
228
     *      array with keys 'text', 'format' and 'itemid'. However, when we are
229
     *      importing, it will be an array with keys 'text', 'format' and 'files'
230
     * @param object $context the context the question is in.
231
     * @param string $filearea indentifies the file area questiontext,
232
     *      generalfeedback, answerfeedback, etc.
233
     * @param int $itemid part or question ID
234
     *
235
     * @return string the text for this field, after files have been processed.
236
     */
237
    protected function save_file_helper(array $array, object $context, string $filearea, int $itemid): string {
238
        return $this->import_or_save_files($array, $context, 'qtype_formulas', $filearea, $itemid);
1,602✔
239
    }
240

241
    /**
242
     * Saves question-type specific options
243
     *
244
     * This is called by {@link save_question()} to save the question-type specific data
245
     * @param object $formdata  This holds the information from the editing form,
246
     *      it is not a standard question object.
247
     * @return object $result->error or $result->notice
248
     * @throws Exception
249
     */
250
    public function save_question_options($formdata) {
251
        global $DB;
252

253
        // Fetch existing parts from the DB.
254
        $existingparts = $DB->get_records('qtype_formulas_answers', ['questionid' => $formdata->id], 'partindex ASC');
1,602✔
255

256
        // Validate the data from the edit form.
257
        $filtered = $this->validate($formdata);
1,602✔
258
        if (!empty($filtered->errors)) {
1,602✔
259
            return (object)['error' => get_string('error_damaged_question', 'qtype_formulas')];
×
260
        }
261

262
        // Order the parts according to how they appear in the question.
263
        $filtered->answers = $this->reorder_parts($formdata->questiontext, $filtered->answers);
1,602✔
264

265
        // Get the question's context. We will need that for handling any files that might have
266
        // been uploaded via the text editors.
267
        $context = $formdata->context;
1,602✔
268

269
        foreach ($filtered->answers as $i => $part) {
1,602✔
270
            $part->questionid = $formdata->id;
1,602✔
271
            $part->partindex = $i;
1,602✔
272

273
            // Try to take the first existing part.
274
            $parttoupdate = array_shift($existingparts);
1,602✔
275

276
            // If there is currently no part, we create an empty one, store it in the DB
277
            // and retrieve its ID.
278
            if (empty($parttoupdate)) {
1,602✔
279
                $config = get_config('qtype_formulas');
1,602✔
280
                $parttoupdate = (object)[
1,602✔
281
                    'questionid' => $formdata->id,
1,602✔
282
                    'answermark' => $config->defaultanswermark,
1,602✔
283
                    'numbox' => 1,
1,602✔
284
                    'answer' => '',
1,602✔
285
                    'answernotunique' => 1,
1,602✔
286
                    'correctness' => '',
1,602✔
287
                    'ruleid' => 1,
1,602✔
288
                    'subqtext' => '',
1,602✔
289
                    'subqtextformat' => FORMAT_HTML,
1,602✔
290
                    'feedback' => '',
1,602✔
291
                    'feedbackformat' => FORMAT_HTML,
1,602✔
292
                    'partcorrectfb' => '',
1,602✔
293
                    'partcorrectfbformat' => FORMAT_HTML,
1,602✔
294
                    'partpartiallycorrectfb' => '',
1,602✔
295
                    'partpartiallycorrectfbformat' => FORMAT_HTML,
1,602✔
296
                    'partincorrectfb' => '',
1,602✔
297
                    'partincorrectfbformat' => FORMAT_HTML,
1,602✔
298
                ];
1,602✔
299

300
                try {
301
                    $parttoupdate->id = $DB->insert_record('qtype_formulas_answers', $parttoupdate);
1,602✔
302
                } catch (Exception $e) {
×
303
                    // TODO: change to non-capturing catch when dropping support for PHP 7.4.
304
                    return (object)['error' => get_string('error_db_write', 'qtype_formulas', 'qtype_formulas_answers')];
×
305
                }
306
            }
307

308
            // Finally, set the ID for the newpart.
309
            $part->id = $parttoupdate->id;
1,602✔
310

311
            // Now that we have the ID, we can deal with the text fields that might contain files,
312
            // i. e. the part's text and the feedbacks (general, correct, partially correct, incorrect).
313
            // We must split up the form's text editor data (text and format in one array) into separate
314
            // text and format properties. Moodle does its magic when saving the files, so we first do
315
            // that and keep the modified text.
316
            // Note that we store the files with the part ID for all text fields that belong to a part.
317
            $tmp = $part->subqtext;
1,602✔
318
            $part->subqtext = $this->save_file_helper($tmp, $context, 'answersubqtext', $part->id);
1,602✔
319
            $part->subqtextformat = $tmp['format'];
1,602✔
320

321
            $tmp = $part->feedback;
1,602✔
322
            $part->feedback = $this->save_file_helper($tmp, $context, 'answerfeedback', $part->id);
1,602✔
323
            $part->feedbackformat = $tmp['format'];
1,602✔
324

325
            $tmp = $part->partcorrectfb;
1,602✔
326
            $part->partcorrectfb = $this->save_file_helper($tmp, $context, 'partcorrectfb', $part->id);
1,602✔
327
            $part->partcorrectfbformat = $tmp['format'];
1,602✔
328

329
            $tmp = $part->partpartiallycorrectfb;
1,602✔
330
            $part->partpartiallycorrectfb = $this->save_file_helper($tmp, $context, 'partpartiallycorrectfb', $part->id);
1,602✔
331
            $part->partpartiallycorrectfbformat = $tmp['format'];
1,602✔
332

333
            $tmp = $part->partincorrectfb;
1,602✔
334
            $part->partincorrectfb = $this->save_file_helper($tmp, $context, 'partincorrectfb', $part->id);
1,602✔
335
            $part->partincorrectfbformat = $tmp['format'];
1,602✔
336

337
            try {
338
                $DB->update_record('qtype_formulas_answers', $part);
1,602✔
339
            } catch (Exception $e) {
×
340
                // TODO: change to non-capturing catch when dropping support for PHP 7.4.
341
                return (object)['error' => get_string('error_db_write', 'qtype_formulas', 'qtype_formulas_answers')];
×
342
            }
343
        }
344

345
        $options = $DB->get_record('qtype_formulas_options', ['questionid' => $formdata->id]);
1,602✔
346

347
        // If there are no options yet (i. e. we are saving a new question) or if the fetch was not
348
        // successful, create new options with default values.
349
        if (empty($options) || $options === false) {
1,602✔
350
            $options = (object)[
1,602✔
351
                'questionid' => $formdata->id,
1,602✔
352
                'correctfeedback' => '',
1,602✔
353
                'partiallycorrectfeedback' => '',
1,602✔
354
                'incorrectfeedback' => '',
1,602✔
355
                'answernumbering' => 'none',
1,602✔
356
            ];
1,602✔
357

358
            try {
359
                $options->id = $DB->insert_record('qtype_formulas_options', $options);
1,602✔
360
            } catch (Exception $e) {
×
361
                return (object)['error' => get_string('error_db_write', 'qtype_formulas', 'qtype_formulas_options')];
×
362
            }
363
        }
364

365
        // Do all the magic for the question's combined feedback fields (correct, partially correct, incorrect).
366
        $options = $this->save_combined_feedback_helper($options, $formdata, $context, true);
1,602✔
367

368
        // Get the extra fields we have for our question type. Drop the first entry, because
369
        // it contains the table name.
370
        $extraquestionfields = $this->extra_question_fields();
1,602✔
371
        array_shift($extraquestionfields);
1,602✔
372

373
        // Assign the values from the form.
374
        foreach ($extraquestionfields as $extrafield) {
1,602✔
375
            if (isset($formdata->$extrafield)) {
1,602✔
376
                $options->$extrafield = $formdata->$extrafield;
1,602✔
377
            }
378
        }
379

380
        // Finally, update the existing (or just recently created) record with the values from the form.
381
        try {
382
            $DB->update_record('qtype_formulas_options', $options);
1,602✔
383
        } catch (Exception $e) {
×
384
            return (object)['error' => get_string('error_db_write', 'qtype_formulas', 'qtype_formulas_options')];
×
385
        }
386

387
        // Save the hints, if they exist.
388
        $this->save_hints($formdata, true);
1,602✔
389

390
        // If there are no existing parts left to be updated, we may leave.
391
        if (!$existingparts) {
1,602✔
392
            return;
1,602✔
393
        }
394

395
        // Still here? Then we must remove remaining parts and their files (if there are), because the
396
        // user seems to have deleted them in the form. This is only important for Moodle 3.11 and lower,
397
        // because from Moodle 4.0 on, the parts and their files will remain in the DB, linked to the
398
        // old question version.
399
        $fs = get_file_storage();
×
400
        foreach ($existingparts as $leftover) {
×
401
            $areas = ['answersubqtext', 'answerfeedback', 'partcorrectfb', 'partpartiallycorrectfb', 'partincorrectfb'];
×
402
            foreach ($areas as $area) {
×
403
                $fs->delete_area_files($context->id, 'qtype_formulas', $area, $leftover->id);
×
404
            }
405
            try {
406
                $DB->delete_records('qtype_formulas_answers', ['id' => $leftover->id]);
×
407
            } catch (Exception $e) {
×
408
                return (object)['error' => get_string('error_db_delete', 'qtype_formulas', 'qtype_formulas_answers')];
×
409
            }
410
        }
411
    }
412

413
    /**
414
     * Save a question. Overriding the parent method, because we have to calculate the
415
     * defaultmark and we need to propagate the global settings for unitpenalty and ruleid
416
     * to every part.
417
     *
418
     * @param object $question
419
     * @param object $formdata
420
     * @return object
421
     */
422
    public function save_question($question, $formdata) {
423
        // Question's default mark is the total of all non empty parts's marks.
424
        $formdata->defaultmark = 0;
1,560✔
425
        foreach (array_keys($formdata->answermark) as $key) {
1,560✔
426
            $formdata->defaultmark += $formdata->answermark[$key];
1,560✔
427
        }
428

429
        // Add the global unitpenalty and ruleid to each part. Using the answertype field as
430
        // the counter reference, because it is always set.
431
        $count = count($formdata->answertype);
1,560✔
432
        $formdata->unitpenalty = array_fill(0, $count, $formdata->globalunitpenalty);
1,560✔
433
        $formdata->ruleid = array_fill(0, $count, $formdata->globalruleid);
1,560✔
434

435
        // Preparation work is done, let the parent method do the rest.
436
        return parent::save_question($question, $formdata);
1,560✔
437
    }
438

439
    /**
440
     * Create a question_hint. Overriding the parent method, because our
441
     * question type can have multiple parts.
442
     *
443
     * @param object $hint the DB row from the question hints table.
444
     * @return question_hint
445
     */
446
    protected function make_hint($hint) {
447
        return question_hint_with_parts::load_from_record($hint);
273✔
448
    }
449

450
    /**
451
     * Delete the question from the database, together with its options and parts.
452
     *
453
     * @param int $questionid
454
     * @param int $contextid
455
     * @return void
456
     */
457
    public function delete_question($questionid, $contextid) {
458
        global $DB;
459

460
        // First, we call the parent method. It will delete the question itself (from question)
461
        // and its options (from qtype_formulas_options).
462
        // Note: This will also trigger the delete_files() method which, in turn, needs the question's
463
        // parts to be available, so we MUST NOT remove the parts before this.
464
        parent::delete_question($questionid, $contextid);
504✔
465

466
        // Finally, remove the related parts from the qtype_formulas_answers table.
467
        $DB->delete_records('qtype_formulas_answers', ['questionid' => $questionid]);
504✔
468
    }
469

470
    /**
471
     * Split the main question text into fragments that will later enclose the various parts'
472
     * text. As an example, 'foo {#1} bar' will become 'foo ' and ' bar'. The function will
473
     * return one more fragment than the number of parts. The last fragment can be empty, e. g.
474
     * if we have a part with no placeholder. Such parts are placed at the very end, so there will
475
     * no fragment of the question's main text after them.
476
     *
477
     * @param string $questiontext main question tex
478
     * @param qtype_formulas_part[] $parts
479
     * @return string[] fragments (one more than the number of parts
480
     */
481
    public function split_questiontext(string $questiontext, array $parts): array {
482
        // Make sure the parts are ordered according to the position of their placeholders
483
        // in the main question text.
484
        $parts = $this->reorder_parts($questiontext, $parts);
630✔
485

486
        $fragments = [];
630✔
487
        foreach ($parts as $part) {
630✔
488
            // Since the parts are ordered, we know that parts with placeholders come first.
489
            // When we see the first part without a placeholder, we can add the remaining question
490
            // text to the fragments. We then set the question text to the empty string, in order
491
            // to add empty fragments for each subsequent part.
492
            if (empty($part->placeholder)) {
630✔
493
                $fragments[] = $questiontext;
609✔
494
                $questiontext = '';
609✔
495
                continue;
609✔
496
            }
497
            $pos = strpos($questiontext, "{{$part->placeholder}}");
21✔
498
            $fragments[] = substr($questiontext, 0, $pos);
21✔
499
            $questiontext = substr($questiontext, $pos + strlen($part->placeholder) + 2);
21✔
500
        }
501

502
        // Add the remainder of the question text after the last part; this might be an empty string.
503
        $fragments[] = $questiontext;
630✔
504

505
        return $fragments;
630✔
506
    }
507

508
    /**
509
     * Initialise instante of the qtype_formulas_question class and its parts which, in turn,
510
     * are instances of the qtype_formulas_part class.
511
     *
512
     * @param qtype_formulas_question $question instance of a Formulas question
513
     * @param object $questiondata question data as stored in the DB
514
     */
515
    protected function initialise_question_instance(question_definition $question, $questiondata) {
516
        // All the classical fields (e. g. category, context or id) are filled by the parent method.
517
        parent::initialise_question_instance($question, $questiondata);
588✔
518

519
        // First, copy some data for the main question.
520
        $question->varsrandom = $questiondata->options->varsrandom;
588✔
521
        $question->varsglobal = $questiondata->options->varsglobal;
588✔
522
        $question->answernumbering = $questiondata->options->answernumbering;
588✔
523
        $question->numparts = $questiondata->options->numparts;
588✔
524

525
        // The attribute $questiondata->options->answers stores all information for the parts. Despite
526
        // its name, it does not only contain the model answers, but also e.g. local or grading vars.
527
        foreach ($questiondata->options->answers as $partdata) {
588✔
528
            $questionpart = new qtype_formulas_part();
588✔
529

530
            // Copy the data fields fetched from the DB to the question part object.
531
            foreach ($partdata as $key => $value) {
588✔
532
                $questionpart->{$key} = $value;
588✔
533
            }
534

535
            // And finally store the populated part in the main question instance.
536
            $question->parts[$partdata->partindex] = $questionpart;
588✔
537
        }
538

539
        // Split the main question text into fragments that will later surround the parts' texts.
540
        $question->textfragments = $this->split_questiontext($question->questiontext, $question->parts);
588✔
541

542
        // The combined feedback will be initialised by the parent class, because we do not override
543
        // this method.
544
        $this->initialise_combined_feedback($question, $questiondata, true);
588✔
545
    }
546

547
    /**
548
     * Return all possible types of response. They are used e. g. in reports.
549
     *
550
     * @param object $questiondata question definition data
551
     * @return array possible responses for every part
552
     */
553
    public function get_possible_responses($questiondata) {
554
        $responses = [];
378✔
555

556
        /** @var qtype_formulas_question $question */
557
        $question = $this->make_question($questiondata);
378✔
558

559
        foreach ($question->parts as $part) {
378✔
560
            if ($part->postunit === '') {
378✔
561
                $responses[$part->partindex] = [
126✔
562
                    'wrong' => new question_possible_response(get_string('response_wrong', 'qtype_formulas'), 0),
126✔
563
                    'right' => new question_possible_response(get_string('response_right', 'qtype_formulas'), 1),
126✔
564
                    null => question_possible_response::no_response(),
126✔
565
                ];
126✔
566
            } else {
567
                $responses[$part->partindex] = [
252✔
568
                    'wrong' => new question_possible_response(get_string('response_wrong', 'qtype_formulas'), 0),
252✔
569
                    'right' => new question_possible_response(get_string('response_right', 'qtype_formulas'), 1),
252✔
570
                    'wrongvalue' => new question_possible_response(get_string('response_wrong_value', 'qtype_formulas'), 0),
252✔
571
                    'wrongunit' => new question_possible_response(
252✔
572
                        get_string('response_wrong_unit', 'qtype_formulas'), 1 - $part->unitpenalty
252✔
573
                    ),
252✔
574
                    null => question_possible_response::no_response(),
252✔
575
                ];
252✔
576
            }
577
        }
578

579
        return $responses;
378✔
580
    }
581

582
    /**
583
     * Imports the question from Moodle XML format. Overriding the parent function is necessary,
584
     * because a Formulas question contains subparts.
585
     *
586
     * @param array $xml structure containing the XML data
587
     * @param $question question object to fill
588
     * @param qformat_xml $format format class exporting the question
589
     * @param $extra extra information (not required for importing this question in this format)
590
     */
591
    public function import_from_xml($xml, $question, qformat_xml $format, $extra = null) {
592
        // Return if data type is not our own one.
593
        if (!isset($xml['@']['type']) || $xml['@']['type'] != $this->name()) {
147✔
594
            return false;
×
595
        }
596

597
        // Import the common question headers and set the corresponding field.
598
        $question = $format->import_headers($xml);
147✔
599
        $question->qtype = $this->name();
147✔
600
        $format->import_combined_feedback($question, $xml, true);
147✔
601
        $format->import_hints($question, $xml, true);
147✔
602

603
        $question->varsrandom = $format->getpath($xml, ['#', 'varsrandom', 0, '#', 'text', 0, '#'], '', true);
147✔
604
        $question->varsglobal = $format->getpath($xml, ['#', 'varsglobal', 0, '#', 'text', 0, '#'], '', true);
147✔
605
        $question->answernumbering = $format->getpath($xml, ['#', 'answernumbering', 0, '#', 'text', 0, '#'], 'none', true);
147✔
606

607
        // Loop over each answer block found in the XML.
608
        foreach ($xml['#']['answers'] as $i => $part) {
147✔
609
            $partindex = $format->getpath($part, ['#', 'partindex', 0 , '#' , 'text' , 0 , '#'], false);
147✔
610
            if ($partindex !== false) {
147✔
611
                $question->partindex[$i] = $partindex;
147✔
612
            }
613
            foreach (self::PART_BASIC_FIELDS as $field) {
147✔
614
                // Older questions do not have this field, so we do not want to issue an error message.
615
                // Also, for maximum backwards compatibility, we set the default value to 1. With this,
616
                // nothing changes for old questions.
617
                if ($field === 'answernotunique') {
147✔
618
                    $ifnotexists = '';
147✔
619
                    $default = '1';
147✔
620
                } else {
621
                    $ifnotexists = get_string('error_import_missing_field', 'qtype_formulas', $field);
147✔
622
                    $default = '0';
147✔
623
                }
624
                $question->{$field}[$i] = $format->getpath(
147✔
625
                    $part,
147✔
626
                    ['#', $field, 0 , '#' , 'text' , 0 , '#'],
147✔
627
                    $default,
147✔
628
                    false,
147✔
629
                    $ifnotexists
147✔
630
                );
147✔
631
            }
632

633
            $subqxml = $format->getpath($part, ['#', 'subqtext', 0], []);
147✔
634
            $question->subqtext[$i] = $format->import_text_with_files($subqxml,
147✔
635
                        [], '', $format->get_format($question->questiontextformat));
147✔
636

637
            $feedbackxml = $format->getpath($part, ['#', 'feedback', 0], []);
147✔
638
            $question->feedback[$i] = $format->import_text_with_files($feedbackxml,
147✔
639
                        [], '', $format->get_format($question->questiontextformat));
147✔
640

641
            $feedbackxml = $format->getpath($part, ['#', 'correctfeedback', 0], []);
147✔
642
            $question->partcorrectfb[$i] = $format->import_text_with_files($feedbackxml,
147✔
643
                        [], '', $format->get_format($question->questiontextformat));
147✔
644
            $feedbackxml = $format->getpath($part, ['#', 'partiallycorrectfeedback', 0], []);
147✔
645
            $question->partpartiallycorrectfb[$i] = $format->import_text_with_files($feedbackxml,
147✔
646
                        [], '', $format->get_format($question->questiontextformat));
147✔
647
            $feedbackxml = $format->getpath($part, ['#', 'incorrectfeedback', 0], []);
147✔
648
            $question->partincorrectfb[$i] = $format->import_text_with_files($feedbackxml,
147✔
649
                        [], '', $format->get_format($question->questiontextformat));
147✔
650
        }
651

652
        // Make the defaultmark consistent if not specified.
653
        $question->defaultmark = array_sum($question->answermark);
147✔
654

655
        return $question;
147✔
656
    }
657

658
    /**
659
     * Exports the question to Moodle XML format.
660
     *
661
     * @param object $question question to be exported into XML format
662
     * @param qformat_xml $format format class exporting the question
663
     * @param $extra extra information (not required for exporting this question in this format)
664
     * @return string containing the question data in XML format
665
     */
666
    public function export_to_xml($question, qformat_xml $format, $extra = null) {
667
        $output = '';
105✔
668
        $contextid = $question->contextid;
105✔
669
        $output .= $format->write_combined_feedback($question->options, $question->id, $question->contextid);
105✔
670

671
        // Get the extra fields we have for our question type. Drop the first entry, because
672
        // it contains the table name.
673
        $extraquestionfields = $this->extra_question_fields();
105✔
674
        array_shift($extraquestionfields);
105✔
675
        foreach ($extraquestionfields as $extrafield) {
105✔
676
            $output .= "<$extrafield>" . $format->writetext($question->options->$extrafield) . "</$extrafield>\n";
105✔
677
        }
678

679
        $fs = get_file_storage();
105✔
680
        foreach ($question->options->answers as $part) {
105✔
681
            $output .= "<answers>\n";
105✔
682
            $output .= " <partindex>\n  " . $format->writetext($part->partindex) . " </partindex>\n";
105✔
683

684
            foreach (self::PART_BASIC_FIELDS as $tag) {
105✔
685
                $output .= " <$tag>\n  " . $format->writetext($part->$tag) . " </$tag>\n";
105✔
686
            }
687

688
            $subqfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'answersubqtext', $part->id);
105✔
689
            $subqtextformat = $format->get_format($part->subqtextformat);
105✔
690
            $output .= " <subqtext format=\"$subqtextformat\">\n";
105✔
691
            $output .= $format->writetext($part->subqtext);
105✔
692
            $output .= $format->write_files($subqfiles);
105✔
693
            $output .= " </subqtext>\n";
105✔
694

695
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'answerfeedback', $part->id);
105✔
696
            $feedbackformat = $format->get_format($part->feedbackformat);
105✔
697
            $output .= " <feedback format=\"$feedbackformat\">\n";
105✔
698
            $output .= $format->writetext($part->feedback);
105✔
699
            $output .= $format->write_files($fbfiles);
105✔
700
            $output .= " </feedback>\n";
105✔
701

702
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'partcorrectfb', $part->id);
105✔
703
            $feedbackformat = $format->get_format($part->partcorrectfbformat);
105✔
704
            $output .= " <correctfeedback format=\"$feedbackformat\">\n";
105✔
705
            $output .= $format->writetext($part->partcorrectfb);
105✔
706
            $output .= $format->write_files($fbfiles);
105✔
707
            $output .= " </correctfeedback>\n";
105✔
708

709
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'partpartiallycorrectfb', $part->id);
105✔
710
            $feedbackformat = $format->get_format($part->partpartiallycorrectfbformat);
105✔
711
            $output .= " <partiallycorrectfeedback format=\"$feedbackformat\">\n";
105✔
712
            $output .= $format->writetext($part->partpartiallycorrectfb);
105✔
713
            $output .= $format->write_files($fbfiles);
105✔
714
            $output .= " </partiallycorrectfeedback>\n";
105✔
715

716
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'partincorrectfb', $part->id);
105✔
717
            $feedbackformat = $format->get_format($part->partincorrectfbformat);
105✔
718
            $output .= " <incorrectfeedback format=\"$feedbackformat\">\n";
105✔
719
            $output .= $format->writetext($part->partincorrectfb);
105✔
720
            $output .= $format->write_files($fbfiles);
105✔
721
            $output .= " </incorrectfeedback>\n";
105✔
722

723
            $output .= "</answers>\n";
105✔
724
        }
725

726
        return $output;
105✔
727
    }
728

729
    /**
730
     * Check if part placeholders are correctly formatted and unique and if each
731
     * placeholder appears exactly once in the main question text.
732
     *
733
     * @param string $questiontext main question text
734
     * @param object[] $parts data relative to each part, coming from the edit form
735
     * @return array $errors possible error messages for each part's placeholder field
736
     */
737
    public function check_placeholders(string $questiontext, array $parts): array {
738
        // Store possible error messages for every part.
739
        $errors = [];
2,862✔
740

741
        // List of placeholders in order to spot duplicates.
742
        $knownplaceholders = [];
2,862✔
743

744
        foreach ($parts as $i => $part) {
2,862✔
745
            // No error if part's placeholder is empty.
746
            if (empty($part->placeholder)) {
2,862✔
747
                continue;
2,688✔
748
            }
749

750
            $errormsgs = [];
174✔
751

752
            // Maximal length for placeholders is limited to 40.
753
            if (strlen($part->placeholder) > 40) {
174✔
754
                $errormsgs[] = get_string('error_placeholder_too_long', 'qtype_formulas');
42✔
755
            }
756
            // Placeholders must start with # and contain only alphanumeric characters or underscores.
757
            if (!preg_match('/^#\w+$/', $part->placeholder) ) {
174✔
758
                $errormsgs[] = get_string('error_placeholder_format', 'qtype_formulas');
42✔
759
            }
760
            // Placeholders must be unique.
761
            if (in_array($part->placeholder, $knownplaceholders)) {
174✔
762
                $errormsgs[] = get_string('error_placeholder_sub_duplicate', 'qtype_formulas');
42✔
763
            }
764
            // Add this placeholder to the list of known values.
765
            $knownplaceholders[] = $part->placeholder;
174✔
766

767
            // Each placeholder must appear exactly once in the main question text.
768
            $count = substr_count($questiontext, "{{$part->placeholder}}");
174✔
769
            if ($count < 1) {
174✔
770
                $errormsgs[] = get_string('error_placeholder_missing', 'qtype_formulas');
21✔
771
            }
772
            if ($count > 1) {
174✔
773
                $errormsgs[] = get_string('error_placeholder_main_duplicate', 'qtype_formulas');
42✔
774
            }
775

776
            // Concatenate all error messages and store them, so they can be shown in the edit form.
777
            // The corresponding field's name is 'placeholder[...]', so we use that as the array key.
778
            if (!empty($errormsgs)) {
174✔
779
                $errors["placeholder[$i]"] = implode(' ', $errormsgs);
105✔
780
            }
781
        }
782

783
        // Return the errors. The array will be empty, if everything was fine.
784
        return $errors;
2,862✔
785
    }
786

787
    /**
788
     * For each part, check that all required fields have been filled and that they are valid.
789
     * Return the filtered data for all parts.
790
     *
791
     * @param object $data data from the edit form (or an import)
792
     * @return object stdClass with properties 'errors' (for errors) and 'parts' (array of stdClass, data for each part)
793
     */
794
    public function check_and_filter_parts(object $data): object {
795
        // This function is also called when importing a question.
796
        // The answers of imported questions already have their unitpenalty and ruleid set.
797
        $isfromimport = property_exists($data, 'unitpenalty') && property_exists($data, 'ruleid');
3,156✔
798

799
        $partdata = [];
3,156✔
800
        $errors = [];
3,156✔
801
        $hasoneanswer = false;
3,156✔
802

803
        foreach (array_keys($data->answermark) as $i) {
3,156✔
804
            // The answermark must not be empty or 0.
805
            $nomark = empty(trim($data->answermark[$i]));
3,156✔
806

807
            // For answers, zero is not nothing... Note that PHP < 8.0 does not consider '1 ' as numeric,
808
            // so we trim first. PHP 8.0+ makes no difference between leading or trailing whitespace.
809
            $noanswer = empty(trim($data->answer[$i])) && !is_numeric(trim($data->answer[$i]));
3,156✔
810
            if ($noanswer === false) {
3,156✔
811
                $hasoneanswer = true;
3,072✔
812
            }
813

814
            // For maximum backwards compatibility, we consider a part as being "empty", if
815
            // has no question text (subqtext), no general feedback (combined feedback was
816
            // probably not taken into account at first, because it was added later) and no
817
            // local vars.
818
            // Note that data from the editors is stored in an array with the keys text, format and itemid.
819
            // Also note that local vars are entered in a textarea (and not an editor) and are PARAM_RAW_TRIMMED.
820
            $noparttext = strlen(trim($data->subqtext[$i]['text'])) === 0;
3,156✔
821
            $nogeneralfb = strlen(trim($data->subqtext[$i]['text'])) === 0;
3,156✔
822
            $nolocalvars = strlen(trim($data->vars1[$i])) === 0;
3,156✔
823
            $emptypart = $noparttext && $nogeneralfb && $nolocalvars;
3,156✔
824

825
            // Having no answermark is only allowed if the part is "empty" AND if there is no answer.
826
            if ($nomark && !($emptypart && $noanswer)) {
3,156✔
827
                $errors["answermark[$i]"] = get_string('error_mark', 'qtype_formulas');
105✔
828
            }
829
            // On the other hand, having no answer is allowed for "empty" parts even if they
830
            // do have an answermark. We do this, because the answermark field is set by default when
831
            // the user clicks the "Blanks for 2 more parts" button. But if they do that by accident
832
            // and don't want those parts, they should not have to worry about them.
833
            if ($noanswer && !$emptypart) {
3,156✔
834
                $errors["answer[$i]"] = get_string('error_answer_missing', 'qtype_formulas');
63✔
835
            }
836

837
            // No need to validate the remainder of this part if there is no answer or no mark.
838
            if ($noanswer || $nomark) {
3,156✔
839
                continue;
252✔
840
            }
841

842
            // The mark must be strictly positive.
843
            if (floatval($data->answermark[$i]) <= 0) {
3,051✔
844
                $errors["answermark[$i]"] = get_string('error_mark', 'qtype_formulas');
42✔
845
            }
846

847
            // The grading criterion must not be empty. Also, if there is no grading criterion, it does
848
            // not make sense to continue the validation.
849
            if (empty(trim($data->correctness[$i]))) {
3,051✔
850
                $errors["correctness[$i]"] = get_string('error_criterion_empty', 'qtype_formulas');
21✔
851
                continue;
21✔
852
            }
853

854
            // Create a stdClass for each part, start by setting the questionid property which is
855
            // common for all parts.
856
            $partdata[$i] = (object)['questionid' => $data->id];
3,030✔
857
            // Set the basic fields, e.g. mark, placeholder or definition of local variables.
858
            foreach (self::PART_BASIC_FIELDS as $field) {
3,030✔
859
                // In the edit form, the part's 'unitpenalty' and 'ruleid' are set via the global options
860
                // 'globalunitpenalty' and 'globalruleid'. When importing a question, they do not need
861
                // special treatment, because they are already stored with the part. Also, all other fields
862
                // are submitted by part and do not need special treatment either.
863
                if (in_array($field, ['ruleid', 'unitpenalty']) && !$isfromimport) {
3,030✔
864
                    $partdata[$i]->$field = trim($data->{'global' . $field});
1,449✔
865
                } else {
866
                    $partdata[$i]->$field = trim($data->{$field}[$i]);
3,030✔
867
                }
868
            }
869

870
            // The various texts are stored as arrays with the keys 'text', 'format' and (if coming from
871
            // the edit form) 'itemid'. We can just copy that over.
872
            $partdata[$i]->subqtext = $data->subqtext[$i];
3,030✔
873
            $partdata[$i]->feedback = $data->feedback[$i];
3,030✔
874
            $partdata[$i]->partcorrectfb = $data->partcorrectfb[$i];
3,030✔
875
            $partdata[$i]->partpartiallycorrectfb = $data->partpartiallycorrectfb[$i];
3,030✔
876
            $partdata[$i]->partincorrectfb = $data->partincorrectfb[$i];
3,030✔
877
        }
878

879
        // If no part has survived the validation, we need to output an error message. There are three
880
        // reasons why a part's validation can fail:
881
        // (a) there is no answermark
882
        // (b) there is no answer
883
        // (c) there is no grading criterion
884
        // If all parts are left empty, they will fail for case (a) and will not have an error message
885
        // attached to the answer field (answer can be empty if part is empty). In that case, we need
886
        // to output an error to the first answer field (and this one only) saying that at least one
887
        // answer is needed. We do not, however, add that error if the first part failed for case (b) and
888
        // thus already has an error message there.
889
        if (count($partdata) === 0 && $hasoneanswer === false) {
3,156✔
890
            if (empty($errors['answer[0]'])) {
84✔
891
                $errors['answer[0]'] = get_string('error_no_answer', 'qtype_formulas');
63✔
892
            }
893
            // If the answermark was left empty or filled with rubbish, the parameter filtering
894
            // will have changed the value to 0, which is not a valid value. If, in addition,
895
            // the part was otherwise empty, that will not have triggered an error message so far,
896
            // because it might have been on purpose (to delete the unused part). But now that
897
            // there seems to be no part left, we should add an error message to the field.
898
            if (empty($data->answermark[$i])) {
84✔
899
                $errors['answermark[0]'] = get_string('error_mark', 'qtype_formulas');
63✔
900
            }
901
        }
902

903
        return (object)['errors' => $errors, 'parts' => $partdata];
3,156✔
904
    }
905

906
    /**
907
     * Check the data from the edit form (or an XML import): parts, answer box placeholders,
908
     * part placeholders and definitions of variables and expressions. At the same time, calculate
909
     * the number of expected answers for every part.
910
     *
911
     * @param object $data
912
     * @return object
913
     */
914
    public function validate(object $data): object {
915
        // Collect all error messages in an associative array of the form 'fieldname' => 'error'.
916
        $errors = [];
3,156✔
917

918
        // The fields 'globalunitpenalty' and 'globalruleid' must be validated separately,
919
        // because they are defined at the question level, even though they affect the parts.
920
        // If we are importing a question, those fields will not be present, because the values
921
        // are already stored with the parts.
922
        // Note: we validate this first, because the fields will be referenced during validation
923
        // of the parts.
924
        $isfromimport = property_exists($data, 'unitpenalty') && property_exists($data, 'ruleid');
3,156✔
925
        if (!$isfromimport) {
3,156✔
926
            $errors += $this->validate_global_unit_fields($data);
1,575✔
927
        }
928

929
        // Check the parts. We get a stdClass with the properties 'errors' (a possibly empty array)
930
        // and 'parts' (an array of stdClass objects, one per part).
931
        $partcheckresult = $this->check_and_filter_parts($data);
3,156✔
932
        $errors += $partcheckresult->errors;
3,156✔
933

934
        // If the basic check failed, we abort and output the error message, because the errors
935
        // might cause other errors downstream.
936
        if (!empty($errors)) {
3,156✔
937
            return (object)['errors' => $errors, 'answers' => null];
315✔
938
        }
939

940
        // From now on, we continue with the checked and filtered parts.
941
        $parts = $partcheckresult->parts;
2,841✔
942

943
        // Make sure that answer box placeholders (if used) are unique for each part.
944
        foreach ($parts as $i => $part) {
2,841✔
945
            try {
946
                qtype_formulas_part::scan_for_answer_boxes($part->subqtext['text'], true);
2,841✔
947
            } catch (Exception $e) {
21✔
948
                $errors["subqtext[$i]"] = $e->getMessage();
21✔
949
            }
950
        }
951

952
        // Separately validate the part placeholders. If we are importing, the question text
953
        // will be a string. If the data comes from the edit from, it is in the editor's
954
        // array structure (text, format, itemid).
955
        $errors += $this->check_placeholders(
2,841✔
956
            is_array($data->questiontext) ? $data->questiontext['text'] : $data->questiontext,
2,841✔
957
            $parts
2,841✔
958
        );
2,841✔
959

960
        // Finally, check definition of variables (local, grading), various expressions
961
        // depending on those variables (model answers, correctness criterion) and unit
962
        // stuff. This check also allows us to calculate the number of answers for each part,
963
        // a value that we store as 'numbox'.
964
        $evaluationresult = $this->check_variables_and_expressions($data, $parts);
2,841✔
965
        $errors += $evaluationresult->errors;
2,841✔
966
        $parts = $evaluationresult->parts;
2,841✔
967

968
        return (object)['errors' => $errors, 'answers' => $parts];
2,841✔
969
    }
970

971
    /**
972
     * This function is called during the validation process to validate the special fields
973
     * 'globalunitpenalty' and 'globalruleid'. Both fields are used as a single option to set
974
     * the unit penalty and the unit conversion rules for all parts of a question.
975
     *
976
     * @param object $data form data to be validated
977
     * @return array array containing error messages or empty array if no error
978
     */
979
    private function validate_global_unit_fields(object $data): array {
980
        $errors = [];
1,575✔
981

982
        if ($data->globalunitpenalty < 0 || $data->globalunitpenalty > 1) {
1,575✔
983
            $errors['globalunitpenalty'] = get_string('error_unitpenalty', 'qtype_formulas');;
42✔
984
        }
985

986
        // If the globalruleid field is missing, that means the request or the form has
987
        // been modified by the user. In that case, we set the id to the invalid value -1
988
        // to simplify the code for the upcoming steps.
989
        if (!isset($data->globalruleid)) {
1,575✔
990
            $data->globalruleid = -1;
21✔
991
        }
992

993
        // Finally, check the global setting for the basic conversion rules. We only check this
994
        // once, because it is the same for all parts.
995
        $conversionrules = new unit_conversion_rules();
1,575✔
996
        $entry = $conversionrules->entry($data->globalruleid);
1,575✔
997
        if ($entry === null || $entry[1] === null) {
1,575✔
998
            $errors['globalruleid'] = get_string('error_ruleid', 'qtype_formulas');
21✔
999
        } else {
1000
            $unitcheck = new answer_unit_conversion();
1,554✔
1001
            $unitcheck->assign_default_rules($data->globalruleid, $entry[1]);
1,554✔
1002
            try {
1003
                $unitcheck->reparse_all_rules();
1,554✔
1004
            } catch (Exception $e) {
×
1005
                // This can only happen if the user has modified (and screwed up) conversion_rules.php.
1006
                // TODO: When refactoring the unit stuff, user-defined rules must be written via the
1007
                // settings page and validated there, the user should not be forced to modify the PHP files,
1008
                // also because they will be overwritten on every update.
1009
                $errors['globalruleid'] = $e->getMessage();
×
1010
            }
1011
        }
1012

1013
        return $errors;
1,575✔
1014
    }
1015

1016
    /**
1017
     * Check definition of variables (local vars, grading vars), various expressions
1018
     * like model answers or correctness criterion and unit stuff. At the same time,
1019
     * calculate the number of answers boxes (to be stored in part->numbox) once the
1020
     * model answers are evaluated. Possible errors are returned in the 'errors' property
1021
     * of the return object. The updated part data (now containing the numbox value)
1022
     * is in the 'parts' property, as an array of objects (one object per part).
1023
     *
1024
     * @param object $data
1025
     * @param object[] $parts
1026
     * @return object stdClass with 'errors' and 'parts'
1027
     */
1028
    public function check_variables_and_expressions(object $data, array $parts): object {
1029
        // Collect all errors.
1030
        $errors = [];
2,841✔
1031

1032
        // Check random variables. If there is an error, we do not continue, because
1033
        // other variables or answers might depend on these definitions.
1034
        try {
1035
            $randomparser = new random_parser($data->varsrandom);
2,841✔
1036
            $evaluator = new evaluator();
2,799✔
1037
            $evaluator->evaluate($randomparser->get_statements());
2,799✔
1038
            $evaluator->instantiate_random_variables();
2,799✔
1039
        } catch (Exception $e) {
42✔
1040
            $errors['varsrandom'] = $e->getMessage();
42✔
1041
            return (object)['errors' => $errors, 'parts' => $parts];
42✔
1042
        }
1043

1044
        // Check global variables. If there is an error, we do not continue, because
1045
        // other variables or answers might depend on these definitions.
1046
        try {
1047
            $globalparser = new parser($data->varsglobal, $randomparser->export_known_variables());
2,799✔
1048
            $evaluator->evaluate($globalparser->get_statements());
2,778✔
1049
        } catch (Exception $e) {
21✔
1050
            $errors['varsglobal'] = $e->getMessage();
21✔
1051
            return (object)['errors' => $errors, 'parts' => $parts];
21✔
1052
        }
1053

1054
        // Check local variables, model answers and grading criterion for each part.
1055
        foreach ($parts as $i => $part) {
2,778✔
1056
            $partevaluator = clone $evaluator;
2,778✔
1057
            $knownvars = $partevaluator->export_variable_list();
2,778✔
1058

1059
            // Validate the local variables for this part. In case of an error, skip the
1060
            // rest of the part, because there might be dependencies.
1061
            $partparser = null;
2,778✔
1062
            if (!empty($part->vars1)) {
2,778✔
1063
                try {
1064
                    $partparser = new parser($part->vars1, $knownvars);
279✔
1065
                    $partevaluator->evaluate($partparser->get_statements());
258✔
1066
                } catch (Exception $e) {
42✔
1067
                    $errors["vars1[$i]"] = $e->getMessage();
42✔
1068
                    continue;
42✔
1069
                }
1070
            }
1071

1072
            // If there were no local variables, the partparser has not been initialized yet.
1073
            // Otherwise, we export its known variables.
1074
            if ($partparser !== null) {
2,736✔
1075
                $knownvars = $partparser->export_known_variables();
237✔
1076
            }
1077

1078
            // Check whether the part uses the algebraic answer type.
1079
            $isalgebraic = $part->answertype == self::ANSWER_TYPE_ALGEBRAIC;
2,736✔
1080

1081
            // Try evaluating the model answers. If this fails, don't validate the rest of
1082
            // this part, because there are dependencies.
1083
            try {
1084
                // If (and only if) the answer is algebraic, the answer parser should
1085
                // interpret ^ as **. The last argument tells the answer parser that we are
1086
                // checking model answers, i. e. the prefix operator is allowed.
1087
                $answerparser = new answer_parser($part->answer, $knownvars, $isalgebraic, true);
2,736✔
1088
                // If the user enters a comment sign in the model answer, it is not technically empty,
1089
                // but it will be parsed as an empty expression. We catch this here and make use of
1090
                // the catch block to pass an error message.
1091
                if (empty($answerparser->get_statements())) {
2,694✔
1092
                    throw new Exception(get_string('error_model_answer_no_content', 'qtype_formulas'));
21✔
1093
                }
1094
                $modelanswers = $partevaluator->evaluate($answerparser->get_statements())[0];
2,673✔
1095
            } catch (Exception $e) {
105✔
1096
                // If the answer type is algebraic, the model answer field must contain one string (with quotes)
1097
                // or an array of strings. Thus, evaluation of the field's content as done above cannot fail,
1098
                // unless that syntax constraint has not been respected by the user.
1099
                if ($isalgebraic) {
105✔
1100
                    $errors["answer[$i]"] = get_string('error_string_for_algebraic_formula', 'qtype_formulas');
21✔
1101
                } else {
1102
                    $errors["answer[$i]"] = $e->getMessage();
84✔
1103
                }
1104
                continue;
105✔
1105
            }
1106

1107
            // Check the model answer. If it is a LIST, make an array out of the individual tokens. If it is
1108
            // a single token, wrap it into a single-element array.
1109
            // Note: If we have e.g. the (global or local) variable 'a=[1,2,3]' and the answer is 'a',
1110
            // this will be considered as three answers 1, 2 and 3. We must maintain that interpretation
1111
            // for backwards compatibility.
1112
            if ($modelanswers->type === token::LIST) {
2,631✔
1113
                $modelanswers = $modelanswers->value;
327✔
1114
            } else {
1115
                $modelanswers = [$modelanswers];
2,304✔
1116
            }
1117

1118
            // As $modelanswers is now an array, we can iterate over all answers. If the answer type is
1119
            // "algebraic formula", they must all be strings. Otherwise, they must all be numbers.
1120
            foreach ($modelanswers as $answer) {
2,631✔
1121
                if ($isalgebraic && $answer->type !== token::STRING) {
2,631✔
1122
                    $errors["answer[$i]"] = get_string('error_string_for_algebraic_formula', 'qtype_formulas');
63✔
1123
                    continue;
63✔
1124
                }
1125
                if (!$isalgebraic && $answer->type !== token::NUMBER) {
2,568✔
1126
                    $errors["answer[$i]"] = get_string('error_number_for_numeric_answertypes', 'qtype_formulas');
21✔
1127
                    continue;
21✔
1128
                }
1129
            }
1130
            // Finally, we convert the array of tokens into an array of literals.
1131
            $modelanswers = token::unpack($modelanswers);
2,631✔
1132

1133
            // Now that we know the model answers, we can set the $numbox property for the part,
1134
            // i. e. the number of answer boxes that are to be shown.
1135
            $part->numbox = count($modelanswers);
2,631✔
1136

1137
            // If the answer type is algebraic, we must now try to do algebraic evaluation of each answer
1138
            // to check for bad formulas.
1139
            if ($isalgebraic) {
2,631✔
1140
                foreach ($modelanswers as $k => $answer) {
363✔
1141
                    // Evaluating the string should give us a numeric value.
1142
                    try {
1143
                        $result = $partevaluator->calculate_algebraic_expression($answer);
363✔
1144
                    } catch (Exception $e) {
42✔
1145
                        $a = (object)[
42✔
1146
                            // Answers are zero-indexed, but users normally count from 1.
1147
                            'answerno' => $k + 1,
42✔
1148
                            // The error message may contain line and column numbers, but they don't make
1149
                            // sense in this context, so we'd rather remove them.
1150
                            'message' => preg_replace('/([^:]+:)([^:]+:)/', '', $e->getMessage()),
42✔
1151
                        ];
42✔
1152
                        $errors["answer[$i]"] = get_string('error_in_answer', 'qtype_formulas', $a);
42✔
1153
                        break;
42✔
1154
                    }
1155
                }
1156
            }
1157

1158
            // If there was an error, we do not continue the validation, because the answers are going to
1159
            // be used for the next steps.
1160
            if (!empty($errors["answer[$i]"])) {
2,631✔
1161
                continue;
126✔
1162
            }
1163

1164
            // In order to prepare the grading variables, we need to have the special vars like
1165
            // _a and _r or _0, _1, ... or _err and _relerr. We will create a dummy part and use it
1166
            // as our worker. Note that the result will automatically flow back into $partevaluator,
1167
            // because the assignment is by reference only.
1168
            $dummypart = new qtype_formulas_part();
2,505✔
1169
            $dummypart->answertype = $part->answertype;
2,505✔
1170
            $dummypart->answer = $part->answer;
2,505✔
1171
            $dummypart->evaluator = $partevaluator;
2,505✔
1172
            try {
1173
                // As we are using the model answers, we must set the third parameter to TRUE, because
1174
                // there might be a PREFIX operator. This is important for answer type algebraic formula.
1175
                $dummypart->add_special_variables($dummypart->get_evaluated_answers(), 1, true);
2,505✔
1176
            } catch (Throwable $e) {
21✔
1177
                // If the last step failed (e. g. because the model answer to an algebraic formula question
1178
                // contained a PREFIX operator), we attach the message to the answer field and stop
1179
                // further validation steps.
1180
                $errors["answer[$i]"] = $e->getMessage();
21✔
1181
                continue;
21✔
1182
            }
1183

1184
            // Validate grading variables.
1185
            if (!empty($part->vars2)) {
2,484✔
1186
                try {
1187
                    $partparser = new parser($part->vars2, $knownvars);
84✔
1188
                    $partevaluator->evaluate($partparser->get_statements());
63✔
1189
                } catch (Exception $e) {
42✔
1190
                    $errors["vars2[$i]"] = $e->getMessage();
42✔
1191
                    continue;
42✔
1192
                }
1193

1194
                // Update the list of known variables.
1195
                $knownvars = $partparser->export_known_variables();
42✔
1196
            }
1197

1198
            // Check grading criterion.
1199
            $grade = 0;
2,442✔
1200
            try {
1201
                $partparser = new parser($part->correctness, $knownvars);
2,442✔
1202
                $result = $partevaluator->evaluate($partparser->get_statements());
2,442✔
1203
                $num = count($result);
2,400✔
1204
                if ($num > 1) {
2,400✔
1205
                    $errors["correctness[$i]"] = get_string('error_grading_single_expression', 'qtype_formulas', $num);
21✔
1206
                }
1207
                $grade = $result[0]->value;
2,400✔
1208
            } catch (Exception $e) {
42✔
1209
                $message = $e->getMessage();
42✔
1210
                // If we are working with the answer type "algebraic formula" and there was an error
1211
                // during evaluation of the grading criterion *and* the error message contains '_relerr',
1212
                // we change the message, because it is save to assume that the teacher tried to use
1213
                // relative error which is not supported with that answer type.
1214
                if ($isalgebraic && strpos($message, '_relerr') !== false) {
42✔
1215
                    $message = get_string('error_algebraic_relerr', 'qtype_formulas');
21✔
1216
                }
1217
                $errors["correctness[$i]"] = $message;
42✔
1218
                continue;
42✔
1219
            }
1220

1221
            // We used the model answers, so the grading criterion should always evaluate to 1 (or more).
1222
            if ($grade < 0.999) {
2,400✔
1223
                $errors["correctness[$i]"] = get_string('error_grading_not_one', 'qtype_formulas', $grade);
21✔
1224
            }
1225

1226
            // Instantiate toolkit class for units.
1227
            $unitcheck = new answer_unit_conversion();
2,400✔
1228

1229
            // If a unit has been provided, check whether it can be parsed.
1230
            if (!empty($part->postunit)) {
2,400✔
1231
                try {
1232
                    $unitparser = new unit_parser($part->postunit);
624✔
1233
                    $unitcheck->parse_targets($unitparser->get_legacy_unit_string());
582✔
1234
                } catch (Exception $e) {
42✔
1235
                    $trace = $e->getTraceAsString();
42✔
1236
                    // If we are coming from the newer code, use the detailed error message without
1237
                    // the row/column number. Otherwise just use the generic message provided by the
1238
                    // legacy code.
1239
                    // TODO: Use str_contains() once we drop support for PHP < 8.0.
1240
                    if (strstr($trace, 'unit_parser.php') !== false) {
42✔
1241
                        // The error message may contain line and column numbers, but they don't make
1242
                        // sense in this context, so we'd rather remove them.
1243
                        $errors["postunit[$i]"] = preg_replace('/([^:]+:)([^:]+:)/', '', $e->getMessage());
42✔
1244

1245
                    } else {
NEW
1246
                        $errors["postunit[$i]"] = get_string('error_unit', 'qtype_formulas');
×
1247
                    }
1248
                }
1249
            }
1250

1251
            // If provided by the user, check the additional conversion rules. We do validate those
1252
            // rules even if the unit has not been set, because we would not want to have invalid stuff
1253
            // in the database.
1254
            if (!empty($part->otherrule)) {
2,400✔
1255
                try {
1256
                    $unitcheck->assign_additional_rules($part->otherrule);
21✔
1257
                    $unitcheck->reparse_all_rules();
21✔
1258
                } catch (Exception $e) {
21✔
1259
                    $errors["otherrule[$i]"] = get_string('error_rule', 'qtype_formulas');
21✔
1260
                }
1261
            }
1262
        }
1263

1264
        return (object)['errors' => $errors, 'parts' => $parts];
2,778✔
1265
    }
1266

1267
    /**
1268
     * Reorder the parts according to the order of placeholders in main question text.
1269
     * Note: the check_placeholder() function should be called before.
1270
     *
1271
     * @param string $questiontext main question text, containing the placeholders
1272
     * @param object[] $parts part data
1273
     * @return object[] sorted parts
1274
     */
1275
    public function reorder_parts(string $questiontext, array $parts): array {
1276
        // Scan question text for part placeholders; $matches[1] will contain a list of
1277
        // the matches in the order of appearance.
1278
        $matches = [];
2,190✔
1279
        preg_match_all('/\{(#\w+)\}/', $questiontext, $matches);
2,190✔
1280

1281
        $ordered = [];
2,190✔
1282

1283
        // First, add the parts with a placeholder, ordered by their appearance.
1284
        foreach ($parts as $part) {
2,190✔
1285
            $newindex = array_search($part->placeholder, $matches[1]);
2,190✔
1286
            if ($newindex !== false) {
2,190✔
1287
                $ordered[$newindex] = $part;
132✔
1288
            }
1289
        }
1290

1291
        // Now, append all remaining parts that do not have a placeholder.
1292
        foreach ($parts as $part) {
2,190✔
1293
            if (empty($part->placeholder)) {
2,190✔
1294
                $ordered[] = $part;
2,079✔
1295
            }
1296
        }
1297

1298
        // Sort the parts by their index and assign result.
1299
        ksort($ordered);
2,190✔
1300

1301
        return $ordered;
2,190✔
1302
    }
1303
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc