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

FormulasQuestion / moodle-qtype_formulas / 17019138792

17 Aug 2025 09:01AM UTC coverage: 97.399% (-0.2%) from 97.629%
17019138792

Pull #264

github

web-flow
Merge f09dd629a into c107cc5e2
Pull Request #264: Allow parts to have empty fields

78 of 92 new or added lines in 10 files covered. (84.78%)

12 existing lines in 5 files now uncovered.

4381 of 4498 relevant lines covered (97.4%)

1618.81 hits per line

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

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

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

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

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

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

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

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

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

63
    /** @var int maximum allowed size for lists (arrays) */
64
    const MAX_LIST_SIZE = 1000;
65

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

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

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

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

113
        return array_keys($parts);
546✔
114
    }
115

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

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

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

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

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

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

164
        $this->delete_files_in_combined_feedback($questionid, $contextid);
525✔
165
        $this->delete_files_in_hints($questionid, $contextid);
525✔
166

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

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

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

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

211
        parent::get_question_options($question);
1,491✔
212

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

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

221
        return true;
1,491✔
222
    }
223

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

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

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

258
        // Validate the data from the edit form.
259
        $filtered = $this->validate($formdata);
1,743✔
260
        if (!empty($filtered->errors)) {
1,743✔
261
            return (object)['error' => implode("\n", $filtered->errors)];
63✔
262
        }
263

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

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

271
        foreach ($filtered->answers as $i => $part) {
1,680✔
272
            $part->questionid = $formdata->id;
1,680✔
273
            $part->partindex = $i;
1,680✔
274

275
            // Try to take the first existing part.
276
            $parttoupdate = array_shift($existingparts);
1,680✔
277

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

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

311
            // Finally, set the ID for the newpart.
312
            $part->id = $parttoupdate->id;
1,680✔
313

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

324
            $tmp = $part->feedback;
1,680✔
325
            $part->feedback = $this->save_file_helper($tmp, $context, 'answerfeedback', $part->id);
1,680✔
326
            $part->feedbackformat = $tmp['format'];
1,680✔
327

328
            $tmp = $part->partcorrectfb;
1,680✔
329
            $part->partcorrectfb = $this->save_file_helper($tmp, $context, 'partcorrectfb', $part->id);
1,680✔
330
            $part->partcorrectfbformat = $tmp['format'];
1,680✔
331

332
            $tmp = $part->partpartiallycorrectfb;
1,680✔
333
            $part->partpartiallycorrectfb = $this->save_file_helper($tmp, $context, 'partpartiallycorrectfb', $part->id);
1,680✔
334
            $part->partpartiallycorrectfbformat = $tmp['format'];
1,680✔
335

336
            $tmp = $part->partincorrectfb;
1,680✔
337
            $part->partincorrectfb = $this->save_file_helper($tmp, $context, 'partincorrectfb', $part->id);
1,680✔
338
            $part->partincorrectfbformat = $tmp['format'];
1,680✔
339

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

348
        $options = $DB->get_record('qtype_formulas_options', ['questionid' => $formdata->id]);
1,680✔
349

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

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

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

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

376
        // Assign the values from the form.
377
        foreach ($extraquestionfields as $extrafield) {
1,680✔
378
            if (isset($formdata->$extrafield)) {
1,680✔
379
                $options->$extrafield = $formdata->$extrafield;
1,680✔
380
            }
381
        }
382

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

390
        // Save the hints, if they exist.
391
        $this->save_hints($formdata, true);
1,680✔
392

393
        // If there are no existing parts left to be updated, we may leave.
394
        if (!$existingparts) {
1,680✔
395
            return;
1,680✔
396
        }
397

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

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

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

438
        // Preparation work is done, let the parent method do the rest.
439
        return parent::save_question($question, $formdata);
1,617✔
440
    }
441

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

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

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

469
        // Finally, remove the related parts from the qtype_formulas_answers table.
470
        $DB->delete_records('qtype_formulas_answers', ['questionid' => $questionid]);
525✔
471
    }
472

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

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

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

508
        return $fragments;
630✔
509
    }
510

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

522
        // First, copy some data for the main question.
523
        /** @var qtype_formulas_question $question */
524
        $question->varsrandom = $questiondata->options->varsrandom;
588✔
525
        $question->varsglobal = $questiondata->options->varsglobal;
588✔
526
        $question->answernumbering = $questiondata->options->answernumbering;
588✔
527
        $question->numparts = $questiondata->options->numparts;
588✔
528

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

534
            // Copy the data fields fetched from the DB to the question part object.
535
            foreach ($partdata as $key => $value) {
588✔
536
                $questionpart->{$key} = $value;
588✔
537
            }
538

539
            // And finally store the populated part in the main question instance.
540
            $question->parts[$partdata->partindex] = $questionpart;
588✔
541
        }
542

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

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

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

560
        /** @var qtype_formulas_question $question */
561
        $question = $this->make_question($questiondata);
378✔
562

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

583
        return $responses;
378✔
584
    }
585

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

602
        // Import the common question headers and set the corresponding field.
603
        $question = $format->import_headers($xml);
231✔
604
        $question->qtype = $this->name();
231✔
605
        $format->import_combined_feedback($question, $xml, true);
231✔
606
        $format->import_hints($question, $xml, true);
231✔
607

608
        $question->varsrandom = $format->getpath($xml, ['#', 'varsrandom', 0, '#', 'text', 0, '#'], '', true);
231✔
609
        $question->varsglobal = $format->getpath($xml, ['#', 'varsglobal', 0, '#', 'text', 0, '#'], '', true);
231✔
610
        $question->answernumbering = $format->getpath($xml, ['#', 'answernumbering', 0, '#', 'text', 0, '#'], 'none', true);
231✔
611

612
        // Loop over each answer block found in the XML.
613
        foreach ($xml['#']['answers'] as $i => $part) {
231✔
614
            $partindex = $format->getpath($part, ['#', 'partindex', 0 , '#' , 'text' , 0 , '#'], false);
231✔
615
            if ($partindex !== false) {
231✔
616
                $question->partindex[$i] = $partindex;
231✔
617
            }
618
            foreach (self::PART_BASIC_FIELDS as $field) {
231✔
619
                // Older questions do not have the 'answernotunique' field, so we do not want to issue an
620
                // error message. For maximum backwards compatibility, we set the default value to 1. With
621
                // this, nothing changes for old questions. Also, questions exported prior to version 6.2
622
                // won't have the 'emptyallowed' field. We will set it to 0 (false) for backwards compatiblity.
623
                if ($field === 'answernotunique') {
231✔
624
                    $ifnotexists = '';
231✔
625
                    $default = '1';
231✔
626
                } else if ($field === 'emptyallowed') {
231✔
627
                    $ifnotexists = '';
231✔
628
                    $default = '0';
231✔
629
                } else {
630
                    $ifnotexists = get_string('error_import_missing_field', 'qtype_formulas', $field);
231✔
631
                    $default = '0';
231✔
632
                }
633
                $question->{$field}[$i] = $format->getpath(
231✔
634
                    $part,
231✔
635
                    ['#', $field, 0 , '#' , 'text' , 0 , '#'],
231✔
636
                    $default,
231✔
637
                    false,
231✔
638
                    $ifnotexists
231✔
639
                );
231✔
640
            }
641

642
            $subqxml = $format->getpath($part, ['#', 'subqtext', 0], []);
231✔
643
            $question->subqtext[$i] = $format->import_text_with_files($subqxml,
231✔
644
                        [], '', $format->get_format($question->questiontextformat));
231✔
645

646
            $feedbackxml = $format->getpath($part, ['#', 'feedback', 0], []);
231✔
647
            $question->feedback[$i] = $format->import_text_with_files($feedbackxml,
231✔
648
                        [], '', $format->get_format($question->questiontextformat));
231✔
649

650
            $feedbackxml = $format->getpath($part, ['#', 'correctfeedback', 0], []);
231✔
651
            $question->partcorrectfb[$i] = $format->import_text_with_files($feedbackxml,
231✔
652
                        [], '', $format->get_format($question->questiontextformat));
231✔
653
            $feedbackxml = $format->getpath($part, ['#', 'partiallycorrectfeedback', 0], []);
231✔
654
            $question->partpartiallycorrectfb[$i] = $format->import_text_with_files($feedbackxml,
231✔
655
                        [], '', $format->get_format($question->questiontextformat));
231✔
656
            $feedbackxml = $format->getpath($part, ['#', 'incorrectfeedback', 0], []);
231✔
657
            $question->partincorrectfb[$i] = $format->import_text_with_files($feedbackxml,
231✔
658
                        [], '', $format->get_format($question->questiontextformat));
231✔
659
        }
660

661
        // Make the defaultmark consistent if not specified.
662
        $question->defaultmark = array_sum($question->answermark);
231✔
663

664
        return $question;
231✔
665
    }
666

667
    /**
668
     * Exports the question to Moodle XML format.
669
     *
670
     * @param object $question question to be exported into XML format
671
     * @param qformat_xml $format format class exporting the question
672
     * @param object $extra extra information (not required for exporting this question in this format)
673
     * @return string containing the question data in XML format
674
     */
675
    public function export_to_xml($question, qformat_xml $format, $extra = null) {
676
        $output = '';
105✔
677
        $contextid = $question->contextid;
105✔
678
        $output .= $format->write_combined_feedback($question->options, $question->id, $question->contextid);
105✔
679

680
        // Get the extra fields we have for our question type. Drop the first entry, because
681
        // it contains the table name.
682
        $extraquestionfields = $this->extra_question_fields();
105✔
683
        array_shift($extraquestionfields);
105✔
684
        foreach ($extraquestionfields as $extrafield) {
105✔
685
            $output .= "<$extrafield>" . $format->writetext($question->options->$extrafield) . "</$extrafield>\n";
105✔
686
        }
687

688
        $fs = get_file_storage();
105✔
689
        foreach ($question->options->answers as $part) {
105✔
690
            $output .= "<answers>\n";
105✔
691
            $output .= " <partindex>\n  " . $format->writetext($part->partindex) . " </partindex>\n";
105✔
692

693
            foreach (self::PART_BASIC_FIELDS as $tag) {
105✔
694
                $output .= " <$tag>\n  " . $format->writetext($part->$tag) . " </$tag>\n";
105✔
695
            }
696

697
            $subqfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'answersubqtext', $part->id);
105✔
698
            $subqtextformat = $format->get_format($part->subqtextformat);
105✔
699
            $output .= " <subqtext format=\"$subqtextformat\">\n";
105✔
700
            $output .= $format->writetext($part->subqtext);
105✔
701
            $output .= $format->write_files($subqfiles);
105✔
702
            $output .= " </subqtext>\n";
105✔
703

704
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'answerfeedback', $part->id);
105✔
705
            $feedbackformat = $format->get_format($part->feedbackformat);
105✔
706
            $output .= " <feedback format=\"$feedbackformat\">\n";
105✔
707
            $output .= $format->writetext($part->feedback);
105✔
708
            $output .= $format->write_files($fbfiles);
105✔
709
            $output .= " </feedback>\n";
105✔
710

711
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'partcorrectfb', $part->id);
105✔
712
            $feedbackformat = $format->get_format($part->partcorrectfbformat);
105✔
713
            $output .= " <correctfeedback format=\"$feedbackformat\">\n";
105✔
714
            $output .= $format->writetext($part->partcorrectfb);
105✔
715
            $output .= $format->write_files($fbfiles);
105✔
716
            $output .= " </correctfeedback>\n";
105✔
717

718
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'partpartiallycorrectfb', $part->id);
105✔
719
            $feedbackformat = $format->get_format($part->partpartiallycorrectfbformat);
105✔
720
            $output .= " <partiallycorrectfeedback format=\"$feedbackformat\">\n";
105✔
721
            $output .= $format->writetext($part->partpartiallycorrectfb);
105✔
722
            $output .= $format->write_files($fbfiles);
105✔
723
            $output .= " </partiallycorrectfeedback>\n";
105✔
724

725
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'partincorrectfb', $part->id);
105✔
726
            $feedbackformat = $format->get_format($part->partincorrectfbformat);
105✔
727
            $output .= " <incorrectfeedback format=\"$feedbackformat\">\n";
105✔
728
            $output .= $format->writetext($part->partincorrectfb);
105✔
729
            $output .= $format->write_files($fbfiles);
105✔
730
            $output .= " </incorrectfeedback>\n";
105✔
731

732
            $output .= "</answers>\n";
105✔
733
        }
734

735
        return $output;
105✔
736
    }
737

738
    /**
739
     * Check if part placeholders are correctly formatted and unique and if each
740
     * placeholder appears exactly once in the main question text.
741
     *
742
     * @param string $questiontext main question text
743
     * @param object[] $parts data relative to each part, coming from the edit form
744
     * @return array $errors possible error messages for each part's placeholder field
745
     */
746
    public function check_placeholders(string $questiontext, array $parts): array {
747
        // Store possible error messages for every part.
748
        $errors = [];
2,982✔
749

750
        // List of placeholders in order to spot duplicates.
751
        $knownplaceholders = [];
2,982✔
752

753
        foreach ($parts as $i => $part) {
2,982✔
754
            // No error if part's placeholder is empty.
755
            if (empty($part->placeholder)) {
2,982✔
756
                continue;
2,808✔
757
            }
758

759
            $errormsgs = [];
174✔
760

761
            // Maximal length for placeholders is limited to 40.
762
            if (strlen($part->placeholder) > 40) {
174✔
763
                $errormsgs[] = get_string('error_placeholder_too_long', 'qtype_formulas');
42✔
764
            }
765
            // Placeholders must start with # and contain only alphanumeric characters or underscores.
766
            if (!preg_match('/^#\w+$/', $part->placeholder) ) {
174✔
767
                $errormsgs[] = get_string('error_placeholder_format', 'qtype_formulas');
42✔
768
            }
769
            // Placeholders must be unique.
770
            if (in_array($part->placeholder, $knownplaceholders)) {
174✔
771
                $errormsgs[] = get_string('error_placeholder_sub_duplicate', 'qtype_formulas');
42✔
772
            }
773
            // Add this placeholder to the list of known values.
774
            $knownplaceholders[] = $part->placeholder;
174✔
775

776
            // Each placeholder must appear exactly once in the main question text.
777
            $count = substr_count($questiontext, "{{$part->placeholder}}");
174✔
778
            if ($count < 1) {
174✔
779
                $errormsgs[] = get_string('error_placeholder_missing', 'qtype_formulas');
21✔
780
            }
781
            if ($count > 1) {
174✔
782
                $errormsgs[] = get_string('error_placeholder_main_duplicate', 'qtype_formulas');
42✔
783
            }
784

785
            // Concatenate all error messages and store them, so they can be shown in the edit form.
786
            // The corresponding field's name is 'placeholder[...]', so we use that as the array key.
787
            if (!empty($errormsgs)) {
174✔
788
                $errors["placeholder[$i]"] = implode(' ', $errormsgs);
105✔
789
            }
790
        }
791

792
        // Return the errors. The array will be empty, if everything was fine.
793
        return $errors;
2,982✔
794
    }
795

796
    /**
797
     * For each part, check that all required fields have been filled and that they are valid.
798
     * Return the filtered data for all parts.
799
     *
800
     * @param object $data data from the edit form (or an import)
801
     * @return object stdClass with properties 'errors' (for errors) and 'parts' (array of stdClass, data for each part)
802
     */
803
    public function check_and_filter_parts(object $data): object {
804
        // This function is also called when importing a question.
805
        // The answers of imported questions already have their unitpenalty and ruleid set.
806
        $isfromimport = property_exists($data, 'unitpenalty') && property_exists($data, 'ruleid');
3,318✔
807

808
        $partdata = [];
3,318✔
809
        $errors = [];
3,318✔
810
        $hasoneanswer = false;
3,318✔
811

812
        foreach (array_keys($data->answermark) as $i) {
3,318✔
813
            // The answermark must not be empty or 0.
814
            $nomark = empty(trim($data->answermark[$i]));
3,318✔
815

816
            // For answers, zero is not nothing... Note that PHP < 8.0 does not consider '1 ' as numeric,
817
            // so we trim first. PHP 8.0+ makes no difference between leading or trailing whitespace.
818
            $noanswer = empty(trim($data->answer[$i])) && !is_numeric(trim($data->answer[$i]));
3,318✔
819
            if ($noanswer === false) {
3,318✔
820
                $hasoneanswer = true;
3,192✔
821
            }
822

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

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

846
            // No need to validate the remainder of this part if there is no answer or no mark.
847
            if ($noanswer || $nomark) {
3,318✔
848
                continue;
294✔
849
            }
850

851
            // The mark must be strictly positive.
852
            if (floatval($data->answermark[$i]) <= 0) {
3,171✔
853
                $errors["answermark[$i]"] = get_string('error_mark', 'qtype_formulas');
42✔
854
            }
855

856
            // The grading criterion must not be empty. Also, if there is no grading criterion, it does
857
            // not make sense to continue the validation.
858
            if (empty(trim($data->correctness[$i])) && !is_numeric(trim($data->correctness[$i]))) {
3,171✔
859
                $errors["correctness[$i]"] = get_string('error_criterion_empty', 'qtype_formulas');
21✔
860
                continue;
21✔
861
            }
862

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

879
            // The various texts are stored as arrays with the keys 'text', 'format' and (if coming from
880
            // the edit form) 'itemid'. We can just copy that over.
881
            $partdata[$i]->subqtext = $data->subqtext[$i];
3,150✔
882
            $partdata[$i]->feedback = $data->feedback[$i];
3,150✔
883
            $partdata[$i]->partcorrectfb = $data->partcorrectfb[$i];
3,150✔
884
            $partdata[$i]->partpartiallycorrectfb = $data->partpartiallycorrectfb[$i];
3,150✔
885
            $partdata[$i]->partincorrectfb = $data->partincorrectfb[$i];
3,150✔
886
        }
887

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

912
        return (object)['errors' => $errors, 'parts' => $partdata];
3,318✔
913
    }
914

915
    /**
916
     * Check the data from the edit form (or an XML import): parts, answer box placeholders,
917
     * part placeholders and definitions of variables and expressions. At the same time, calculate
918
     * the number of expected answers for every part.
919
     *
920
     * @param object $data
921
     * @return object
922
     */
923
    public function validate(object $data): object {
924
        // Collect all error messages in an associative array of the form 'fieldname' => 'error'.
925
        $errors = [];
3,318✔
926

927
        // The fields 'globalunitpenalty' and 'globalruleid' must be validated separately,
928
        // because they are defined at the question level, even though they affect the parts.
929
        // If we are importing a question, those fields will not be present, because the values
930
        // are already stored with the parts.
931
        // Note: we validate this first, because the fields will be referenced during validation
932
        // of the parts.
933
        $isfromimport = property_exists($data, 'unitpenalty') && property_exists($data, 'ruleid');
3,318✔
934
        if (!$isfromimport) {
3,318✔
935
            $errors += $this->validate_global_unit_fields($data);
1,596✔
936
        }
937

938
        // Check the parts. We get a stdClass with the properties 'errors' (a possibly empty array)
939
        // and 'parts' (an array of stdClass objects, one per part).
940
        $partcheckresult = $this->check_and_filter_parts($data);
3,318✔
941
        $errors += $partcheckresult->errors;
3,318✔
942

943
        // If the basic check failed, we abort and output the error message, because the errors
944
        // might cause other errors downstream.
945
        if (!empty($errors)) {
3,318✔
946
            return (object)['errors' => $errors, 'answers' => null];
357✔
947
        }
948

949
        // From now on, we continue with the checked and filtered parts.
950
        $parts = $partcheckresult->parts;
2,961✔
951

952
        // Make sure that answer box placeholders (if used) are unique for each part.
953
        foreach ($parts as $i => $part) {
2,961✔
954
            try {
955
                qtype_formulas_part::scan_for_answer_boxes($part->subqtext['text'], true);
2,961✔
956
            } catch (Exception $e) {
21✔
957
                $errors["subqtext[$i]"] = $e->getMessage();
21✔
958
            }
959
        }
960

961
        // Separately validate the part placeholders. If we are importing, the question text
962
        // will be a string. If the data comes from the edit from, it is in the editor's
963
        // array structure (text, format, itemid).
964
        $errors += $this->check_placeholders(
2,961✔
965
            is_array($data->questiontext) ? $data->questiontext['text'] : $data->questiontext,
2,961✔
966
            $parts
2,961✔
967
        );
2,961✔
968

969
        // Finally, check definition of variables (local, grading), various expressions
970
        // depending on those variables (model answers, correctness criterion) and unit
971
        // stuff. This check also allows us to calculate the number of answers for each part,
972
        // a value that we store as 'numbox'.
973
        $evaluationresult = $this->check_variables_and_expressions($data, $parts, $isfromimport);
2,961✔
974
        $errors += $evaluationresult->errors;
2,961✔
975
        $parts = $evaluationresult->parts;
2,961✔
976

977
        return (object)['errors' => $errors, 'answers' => $parts];
2,961✔
978
    }
979

980
    /**
981
     * This function is called during the validation process to validate the special fields
982
     * 'globalunitpenalty' and 'globalruleid'. Both fields are used as a single option to set
983
     * the unit penalty and the unit conversion rules for all parts of a question.
984
     *
985
     * @param object $data form data to be validated
986
     * @return array array containing error messages or empty array if no error
987
     */
988
    private function validate_global_unit_fields(object $data): array {
989
        $errors = [];
1,596✔
990

991
        if ($data->globalunitpenalty < 0 || $data->globalunitpenalty > 1) {
1,596✔
992
            $errors['globalunitpenalty'] = get_string('error_unitpenalty', 'qtype_formulas');;
42✔
993
        }
994

995
        // If the globalruleid field is missing, that means the request or the form has
996
        // been modified by the user. In that case, we set the id to the invalid value -1
997
        // to simplify the code for the upcoming steps.
998
        if (!isset($data->globalruleid)) {
1,596✔
999
            $data->globalruleid = -1;
21✔
1000
        }
1001

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

1022
        return $errors;
1,596✔
1023
    }
1024

1025
    /**
1026
     * Check definition of variables (local vars, grading vars), various expressions
1027
     * like model answers or correctness criterion and unit stuff. At the same time,
1028
     * calculate the number of answers boxes (to be stored in part->numbox) once the
1029
     * model answers are evaluated. Possible errors are returned in the 'errors' property
1030
     * of the return object. The updated part data (now containing the numbox value)
1031
     * is in the 'parts' property, as an array of objects (one object per part).
1032
     *
1033
     * @param object $data
1034
     * @param object[] $parts
1035
     * @param bool $fromimport whether the check is performed during an import process
1036
     * @return object stdClass with 'errors' and 'parts'
1037
     */
1038
    public function check_variables_and_expressions(object $data, array $parts, bool $fromimport = false): object {
1039
        // Collect all errors.
1040
        $errors = [];
2,961✔
1041

1042
        // Check random variables. If there is an error, we do not continue, because
1043
        // other variables or answers might depend on these definitions.
1044
        try {
1045
            $randomparser = new random_parser($data->varsrandom);
2,961✔
1046
            $evaluator = new evaluator();
2,919✔
1047
            $evaluator->evaluate($randomparser->get_statements());
2,919✔
1048
            $evaluator->instantiate_random_variables();
2,919✔
1049
        } catch (Exception $e) {
42✔
1050
            $errors['varsrandom'] = $e->getMessage();
42✔
1051
            return (object)['errors' => $errors, 'parts' => $parts];
42✔
1052
        }
1053

1054
        // Check global variables. If there is an error, we do not continue, because
1055
        // other variables or answers might depend on these definitions.
1056
        try {
1057
            $globalparser = new parser($data->varsglobal, $randomparser->export_known_variables());
2,919✔
1058
            $evaluator->evaluate($globalparser->get_statements());
2,898✔
1059
        } catch (Exception $e) {
21✔
1060
            $errors['varsglobal'] = $e->getMessage();
21✔
1061
            return (object)['errors' => $errors, 'parts' => $parts];
21✔
1062
        }
1063

1064
        // Check local variables, model answers and grading criterion for each part.
1065
        foreach ($parts as $i => $part) {
2,898✔
1066
            $partevaluator = clone $evaluator;
2,898✔
1067
            $knownvars = $partevaluator->export_variable_list();
2,898✔
1068

1069
            // Validate the local variables for this part. In case of an error, skip the
1070
            // rest of the part, because there might be dependencies.
1071
            $partparser = null;
2,898✔
1072
            if (!empty($part->vars1)) {
2,898✔
1073
                try {
1074
                    $partparser = new parser($part->vars1, $knownvars);
279✔
1075
                    $partevaluator->evaluate($partparser->get_statements());
258✔
1076
                } catch (Exception $e) {
42✔
1077
                    $errors["vars1[$i]"] = $e->getMessage();
42✔
1078
                    continue;
42✔
1079
                }
1080
            }
1081

1082
            // If there were no local variables, the partparser has not been initialized yet.
1083
            // Otherwise, we export its known variables.
1084
            if ($partparser !== null) {
2,856✔
1085
                $knownvars = $partparser->export_known_variables();
237✔
1086
            }
1087

1088
            // Check whether the part uses the algebraic answer type.
1089
            $isalgebraic = $part->answertype == self::ANSWER_TYPE_ALGEBRAIC;
2,856✔
1090

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

1117
            // Check the model answer. If it is a LIST, make an array out of the individual tokens. If it is
1118
            // a single token, wrap it into a single-element array.
1119
            // Note: If we have e.g. the (global or local) variable 'a=[1,2,3]' and the answer is 'a',
1120
            // this will be considered as three answers 1, 2 and 3. We must maintain that interpretation
1121
            // for backwards compatibility.
1122
            if ($modelanswers->type === token::LIST) {
2,730✔
1123
                $modelanswers = $modelanswers->value;
327✔
1124
            } else {
1125
                $modelanswers = [$modelanswers];
2,403✔
1126
            }
1127

1128
            // As $modelanswers is now an array, we can iterate over all answers. If the answer type is
1129
            // "algebraic formula", they must all be strings. Otherwise, they must all be numbers or
1130
            // at least numeric strings.
1131
            foreach ($modelanswers as $answer) {
2,730✔
1132
                if ($isalgebraic && ($answer->type !== token::STRING && $answer->type !== token::EMPTY)) {
2,730✔
1133
                    $errors["answer[$i]"] = get_string('error_string_for_algebraic_formula', 'qtype_formulas');
63✔
1134
                    continue;
63✔
1135
                }
1136
                if (!$isalgebraic && !($answer->type === token::EMPTY || $answer->type === token::NUMBER || is_numeric($answer->value))) {
2,667✔
1137
                    $errors["answer[$i]"] = get_string('error_number_for_numeric_answertypes', 'qtype_formulas');
21✔
1138
                    continue;
21✔
1139
                }
1140
            }
1141
            // Finally, we convert the array of tokens into an array of literals.
1142
            // FIXME: remove this
1143
            // $modelanswers = token::unpack($modelanswers);
1144

1145
            // Now that we know the model answers, we can set the $numbox property for the part,
1146
            // i. e. the number of answer boxes that are to be shown.
1147
            $part->numbox = count($modelanswers);
2,730✔
1148

1149
            // If the answer type is algebraic, we must now try to do algebraic evaluation of each answer
1150
            // to check for bad formulas.
1151
            if ($isalgebraic) {
2,730✔
1152
                foreach ($modelanswers as $k => $answertoken) {
363✔
1153
                    // If it is the $EMPTY token, we have nothing to do.
1154
                    if ($answertoken->type === token::EMPTY) {
363✔
NEW
UNCOV
1155
                        continue;
×
1156
                    }
1157
                    // Evaluating the string should give us a numeric value.
1158
                    try {
1159
                        $result = $partevaluator->calculate_algebraic_expression($answertoken->value);
363✔
1160
                    } catch (Exception $e) {
42✔
1161
                        $a = (object)[
42✔
1162
                            // Answers are zero-indexed, but users normally count from 1.
1163
                            'answerno' => $k + 1,
42✔
1164
                            // The error message may contain line and column numbers, but they don't make
1165
                            // sense in this context, so we'd rather remove them.
1166
                            'message' => preg_replace('/([^:]+:)([^:]+:)/', '', $e->getMessage()),
42✔
1167
                        ];
42✔
1168
                        $errors["answer[$i]"] = get_string('error_in_answer', 'qtype_formulas', $a);
42✔
1169
                        break;
42✔
1170
                    }
1171
                }
1172
            }
1173

1174
            // If there was an error, we do not continue the validation, because the answers are going to
1175
            // be used for the next steps.
1176
            if (!empty($errors["answer[$i]"])) {
2,730✔
1177
                continue;
126✔
1178
            }
1179

1180
            // In order to prepare the grading variables, we need to have the special vars like
1181
            // _a and _r or _0, _1, ... or _err and _relerr. We will create a dummy part and use it
1182
            // as our worker. Note that the result will automatically flow back into $partevaluator,
1183
            // because the assignment is by reference only.
1184
            $dummypart = new qtype_formulas_part();
2,604✔
1185
            $dummypart->answertype = $part->answertype;
2,604✔
1186
            $dummypart->answer = $part->answer;
2,604✔
1187
            $dummypart->evaluator = $partevaluator;
2,604✔
1188
            try {
1189
                // As we are using the model answers, we must set the third parameter to TRUE, because
1190
                // there might be a PREFIX operator. This is important for answer type algebraic formula.
1191
                $dummypart->add_special_variables($dummypart->get_evaluated_answers(), 1, true);
2,604✔
1192
            } catch (Throwable $e) {
21✔
1193
                // If the last step failed (e. g. because the model answer to an algebraic formula question
1194
                // contained a PREFIX operator), we attach the message to the answer field and stop
1195
                // further validation steps.
1196
                $errors["answer[$i]"] = $e->getMessage();
21✔
1197
                continue;
21✔
1198
            }
1199

1200
            // Validate grading variables.
1201
            if (!empty($part->vars2)) {
2,583✔
1202
                try {
1203
                    $partparser = new parser($part->vars2, $knownvars);
84✔
1204
                    $partevaluator->evaluate($partparser->get_statements());
63✔
1205
                } catch (Exception $e) {
42✔
1206
                    $errors["vars2[$i]"] = $e->getMessage();
42✔
1207
                    continue;
42✔
1208
                }
1209

1210
                // Update the list of known variables.
1211
                $knownvars = $partparser->export_known_variables();
42✔
1212
            }
1213

1214
            // Check grading criterion.
1215
            $grade = 0;
2,541✔
1216
            try {
1217
                $partparser = new parser($part->correctness, $knownvars);
2,541✔
1218
                $result = $partevaluator->evaluate($partparser->get_statements());
2,541✔
1219
                $num = count($result);
2,499✔
1220
                if ($num > 1) {
2,499✔
1221
                    $errors["correctness[$i]"] = get_string('error_grading_single_expression', 'qtype_formulas', $num);
21✔
1222
                }
1223
                $grade = $result[0]->value;
2,499✔
1224
            } catch (Exception $e) {
42✔
1225
                $message = $e->getMessage();
42✔
1226
                // If we are working with the answer type "algebraic formula" and there was an error
1227
                // during evaluation of the grading criterion *and* the error message contains '_relerr',
1228
                // we change the message, because it is save to assume that the teacher tried to use
1229
                // relative error which is not supported with that answer type.
1230
                if ($isalgebraic && strpos($message, '_relerr') !== false) {
42✔
1231
                    $message = get_string('error_algebraic_relerr', 'qtype_formulas');
21✔
1232
                }
1233
                $errors["correctness[$i]"] = $message;
42✔
1234
                continue;
42✔
1235
            }
1236

1237
            // We used the model answers, so the grading criterion should always evaluate to 1 (or more).
1238
            // This check is omitted when importing questions, because it did not exist in legacy versions,
1239
            // so teachers might have questions with "wrong" model answers and their import will fail.
1240
            $lenientimport = get_config('qtype_formulas', 'lenientimport');
2,499✔
1241
            $checkthis = !$lenientimport || !$fromimport;
2,499✔
1242
            if ($checkthis && $grade < 0.999) {
2,499✔
1243
                $errors["correctness[$i]"] = get_string('error_grading_not_one', 'qtype_formulas', $grade);
63✔
1244
            }
1245

1246
            // Instantiate toolkit class for units.
1247
            $unitcheck = new answer_unit_conversion();
2,499✔
1248

1249
            // If a unit has been provided, check whether it can be parsed.
1250
            if (!empty($part->postunit)) {
2,499✔
1251
                try {
1252
                    $unitcheck->parse_targets($part->postunit);
639✔
1253
                } catch (Exception $e) {
21✔
1254
                    $errors["postunit[$i]"] = get_string('error_unit', 'qtype_formulas');
21✔
1255
                }
1256
            }
1257

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

1271
        return (object)['errors' => $errors, 'parts' => $parts];
2,898✔
1272
    }
1273

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

1288
        $ordered = [];
2,268✔
1289

1290
        // First, add the parts with a placeholder, ordered by their appearance.
1291
        foreach ($parts as $part) {
2,268✔
1292
            $newindex = array_search($part->placeholder, $matches[1]);
2,268✔
1293
            if ($newindex !== false) {
2,268✔
1294
                $ordered[$newindex] = $part;
132✔
1295
            }
1296
        }
1297

1298
        // Now, append all remaining parts that do not have a placeholder.
1299
        foreach ($parts as $part) {
2,268✔
1300
            if (empty($part->placeholder)) {
2,268✔
1301
                $ordered[] = $part;
2,157✔
1302
            }
1303
        }
1304

1305
        // Sort the parts by their index and assign result.
1306
        ksort($ordered);
2,268✔
1307

1308
        return $ordered;
2,268✔
1309
    }
1310

1311
    /**
1312
     * Similar to Moodle's format_float() function, this function will output a float number
1313
     * with the locale's decimal separator. It checks the admin setting (allowdecimalcomma)
1314
     * before doing so; if the decimal comma is not activated, the point will be used in
1315
     * all cases. We are not using the library function, because it can lead to unnecessary
1316
     * trailing zeroes. When using the function for LaTeX output, it will wrap the comma in
1317
     * curly braces for correct spacing.
1318
     *
1319
     * @param float|string $float a number or numeric string to be formatted
1320
     * @param bool $forlatex whether the output is for LaTeX code
1321
     * @return string
1322
     */
1323
    public static function format_float($float, bool $forlatex = false): string {
1324
        // If use of decimal comma (or other local decimal separator) is not allowed, we
1325
        // return the number as-is.
1326
        if (!get_config('qtype_formulas', 'allowdecimalcomma')) {
2,883✔
1327
            return strval($float);
2,883✔
1328
        }
1329

1330
        // Get the correct decimal separator according to the user's locale. If necessary,
1331
        // put braces around it.
1332
        $decsep = get_string('decsep', 'langconfig');
42✔
1333
        if ($forlatex && $decsep === ',') {
42✔
1334
            $decsep = '{' . $decsep . '}';
21✔
1335
        }
1336

1337
        return str_replace('.', $decsep, strval($float));
42✔
1338
    }
1339
}
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