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

FormulasQuestion / moodle-qtype_formulas / 24044503888

06 Apr 2026 06:21PM UTC coverage: 97.22% (-0.3%) from 97.498%
24044503888

Pull #264

github

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

80 of 92 new or added lines in 11 files covered. (86.96%)

3 existing lines in 1 file now uncovered.

4652 of 4785 relevant lines covered (97.22%)

959.31 hits per line

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

95.22
/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\formulas_part;
31
use qtype_formulas\local\random_parser;
32
use qtype_formulas\local\answer_parser;
33
use qtype_formulas\local\parser;
34
use qtype_formulas\local\token;
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
    /** @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'];
1,639✔
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]);
330✔
112

113
        return array_keys($parts);
330✔
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);
55✔
126

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

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

140
        // The parent method will move files from the question text and the general feedback.
141
        parent::move_files($questionid, $oldcontextid, $newcontextid);
55✔
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);
319✔
153

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

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

167
        // The parent method will delete files from the question text and the general feedback.
168
        parent::delete_files($questionid, $contextid);
319✔
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,276✔
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,276✔
195
            debugging(get_string('error_db_missing_options', 'qtype_formulas', $question->id), DEBUG_DEVELOPER);
22✔
196
            $question->options = (object)[
22✔
197
                'questionid' => $question->id,
22✔
198
                'varsrandom' => '',
22✔
199
                'varsglobal' => '',
22✔
200
                'correctfeedback' => get_string('correctfeedbackdefault', 'question'),
22✔
201
                'correctfeedbackformat' => FORMAT_HTML,
22✔
202
                'partiallycorrectfeedback' => get_string('partiallycorrectfeedbackdefault', 'question'),
22✔
203
                'partiallycorrectfeedbackformat' => FORMAT_HTML,
22✔
204
                'incorrectfeedback' => get_string('incorrectfeedbackdefault', 'question'),
22✔
205
                'incorrectfeedbackformat' => FORMAT_HTML,
22✔
206
                'shownumcorrect' => 0,
22✔
207
                'answernumbering' => 'none',
22✔
208
            ];
22✔
209
        }
210

211
        parent::get_question_options($question);
1,276✔
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,276✔
216
        $question->options->answers = array_values($question->options->answers);
1,276✔
217

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

221
        return true;
1,276✔
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,375✔
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,408✔
257

258
        // Validate the data from the edit form.
259
        $filtered = $this->validate($formdata);
1,408✔
260
        if (!empty($filtered->errors)) {
1,408✔
261
            return (object)['error' => implode("\n", $filtered->errors)];
33✔
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,375✔
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,375✔
270

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

275
            // Try to take the first existing part.
276
            $parttoupdate = array_shift($existingparts);
1,375✔
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,375✔
281
                $config = get_config('qtype_formulas');
1,375✔
282
                $parttoupdate = (object)[
1,375✔
283
                    'questionid' => $formdata->id,
1,375✔
284
                    'answermark' => $config->defaultanswermark,
1,375✔
285
                    'numbox' => 1,
1,375✔
286
                    'answer' => '',
1,375✔
287
                    'answernotunique' => 1,
1,375✔
288
                    'emptyallowed' => 1,
1,375✔
289
                    'correctness' => '',
1,375✔
290
                    'ruleid' => 1,
1,375✔
291
                    'subqtext' => '',
1,375✔
292
                    'subqtextformat' => FORMAT_HTML,
1,375✔
293
                    'feedback' => '',
1,375✔
294
                    'feedbackformat' => FORMAT_HTML,
1,375✔
295
                    'partcorrectfb' => '',
1,375✔
296
                    'partcorrectfbformat' => FORMAT_HTML,
1,375✔
297
                    'partpartiallycorrectfb' => '',
1,375✔
298
                    'partpartiallycorrectfbformat' => FORMAT_HTML,
1,375✔
299
                    'partincorrectfb' => '',
1,375✔
300
                    'partincorrectfbformat' => FORMAT_HTML,
1,375✔
301
                ];
1,375✔
302

303
                try {
304
                    $parttoupdate->id = $DB->insert_record('qtype_formulas_answers', $parttoupdate);
1,375✔
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,375✔
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,375✔
321
            $part->subqtext = $this->save_file_helper($tmp, $context, 'answersubqtext', $part->id);
1,375✔
322
            $part->subqtextformat = $tmp['format'];
1,375✔
323

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

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

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

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

340
            try {
341
                $DB->update_record('qtype_formulas_answers', $part);
1,375✔
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,375✔
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,375✔
353
            $options = (object)[
1,375✔
354
                'questionid' => $formdata->id,
1,375✔
355
                'correctfeedback' => '',
1,375✔
356
                'partiallycorrectfeedback' => '',
1,375✔
357
                'incorrectfeedback' => '',
1,375✔
358
                'answernumbering' => 'none',
1,375✔
359
            ];
1,375✔
360

361
            try {
362
                $options->id = $DB->insert_record('qtype_formulas_options', $options);
1,375✔
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,375✔
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,375✔
374
        array_shift($extraquestionfields);
1,375✔
375

376
        // Assign the values from the form.
377
        foreach ($extraquestionfields as $extrafield) {
1,375✔
378
            if (isset($formdata->$extrafield)) {
1,375✔
379
                $options->$extrafield = $formdata->$extrafield;
1,375✔
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,375✔
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,375✔
392

393
        // If there are no existing parts left to be updated, we may leave.
394
        if (!$existingparts) {
1,375✔
395
            return;
1,375✔
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,342✔
428
        foreach (array_keys($formdata->answermark) as $key) {
1,342✔
429
            $formdata->defaultmark += $formdata->answermark[$key];
1,342✔
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,342✔
435
        $formdata->unitpenalty = array_fill(0, $count, $formdata->globalunitpenalty);
1,342✔
436
        $formdata->ruleid = array_fill(0, $count, $formdata->globalruleid);
1,342✔
437

438
        // Preparation work is done, let the parent method do the rest.
439
        return parent::save_question($question, $formdata);
1,342✔
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);
165✔
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);
319✔
468

469
        // Finally, remove the related parts from the qtype_formulas_answers table.
470
        $DB->delete_records('qtype_formulas_answers', ['questionid' => $questionid]);
319✔
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 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);
352✔
488

489
        $fragments = [];
352✔
490
        foreach ($parts as $part) {
352✔
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)) {
352✔
496
                $fragments[] = $questiontext;
341✔
497
                $questiontext = '';
341✔
498
                continue;
341✔
499
            }
500
            $pos = strpos($questiontext, "{{$part->placeholder}}");
11✔
501
            $fragments[] = substr($questiontext, 0, $pos);
11✔
502
            $questiontext = substr($questiontext, $pos + strlen($part->placeholder) + 2);
11✔
503
        }
504

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

508
        return $fragments;
352✔
509
    }
510

511
    /**
512
     * Initialise instante of the qtype_formulas_question class and its parts which, in turn,
513
     * are instances of the 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);
330✔
521

522
        // First, copy some data for the main question.
523
        /** @var qtype_formulas_question $question */
524
        $question->varsrandom = $questiondata->options->varsrandom;
330✔
525
        $question->varsglobal = $questiondata->options->varsglobal;
330✔
526
        $question->answernumbering = $questiondata->options->answernumbering;
330✔
527
        $question->numparts = $questiondata->options->numparts;
330✔
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) {
330✔
532
            $questionpart = new formulas_part();
330✔
533

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

539
            // And finally store the populated part in the main question instance.
540
            $question->parts[$partdata->partindex] = $questionpart;
330✔
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);
330✔
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);
330✔
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 = [];
198✔
559

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

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

584
        return $responses;
198✔
585
    }
586

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

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

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

613
        // If there are no answers (parts) in the XML, fetch a pseudo-path in order to generate an error
614
        // in the same format as for missing fields.
615
        if (!isset($xml['#']['answers'])) {
143✔
616
            $xml['#']['answers'] = [];
11✔
617
            $errormessage = get_string('error_import_missing_parts', 'qtype_formulas', $question->name);
11✔
618
            $format->getpath($xml, ['#', 'xxx'], null, false, $errormessage);
11✔
619
        }
620
        // Otherwise, loop over each answer block found in the XML.
621
        foreach ($xml['#']['answers'] as $i => $part) {
143✔
622
            $partindex = $format->getpath($part, ['#', 'partindex', 0, '#', 'text', 0, '#'], false);
132✔
623
            if ($partindex !== false) {
132✔
624
                $question->partindex[$i] = $partindex;
132✔
625
            }
626
            foreach (self::PART_BASIC_FIELDS as $field) {
132✔
627
                // Older questions do not have the 'answernotunique' field, so we do not want to issue an
628
                // error message. For maximum backwards compatibility, we set the default value to 1. With
629
                // this, nothing changes for old questions. Also, questions exported prior to version 6.2
630
                // won't have the 'emptyallowed' field. We will set it to 0 (false) for backwards compatiblity.
631
                if ($field === 'answernotunique') {
132✔
632
                    $ifnotexists = '';
132✔
633
                    $default = '1';
132✔
634
                } else if ($field === 'emptyallowed') {
132✔
635
                    $ifnotexists = '';
132✔
636
                    $default = '0';
132✔
637
                } else {
638
                    $ifnotexists = get_string('error_import_missing_field', 'qtype_formulas', $field);
132✔
639
                    $default = '0';
132✔
640
                }
641
                $question->{$field}[$i] = $format->getpath(
132✔
642
                    $part,
132✔
643
                    ['#', $field, 0, '#', 'text', 0, '#'],
132✔
644
                    $default,
132✔
645
                    false,
132✔
646
                    $ifnotexists,
132✔
647
                );
132✔
648
            }
649

650
            $subqxml = $format->getpath($part, ['#', 'subqtext', 0], []);
132✔
651
            $question->subqtext[$i] = $format->import_text_with_files(
132✔
652
                $subqxml,
132✔
653
                [],
132✔
654
                '',
132✔
655
                $format->get_format($question->questiontextformat),
132✔
656
            );
132✔
657

658
            $feedbackxml = $format->getpath($part, ['#', 'feedback', 0], []);
132✔
659
            $question->feedback[$i] = $format->import_text_with_files(
132✔
660
                $feedbackxml,
132✔
661
                [],
132✔
662
                '',
132✔
663
                $format->get_format($question->questiontextformat),
132✔
664
            );
132✔
665

666
            $feedbackxml = $format->getpath($part, ['#', 'correctfeedback', 0], []);
132✔
667
            $question->partcorrectfb[$i] = $format->import_text_with_files(
132✔
668
                $feedbackxml,
132✔
669
                [],
132✔
670
                '',
132✔
671
                $format->get_format($question->questiontextformat),
132✔
672
            );
132✔
673
            $feedbackxml = $format->getpath($part, ['#', 'partiallycorrectfeedback', 0], []);
132✔
674
            $question->partpartiallycorrectfb[$i] = $format->import_text_with_files(
132✔
675
                $feedbackxml,
132✔
676
                [],
132✔
677
                '',
132✔
678
                $format->get_format($question->questiontextformat),
132✔
679
            );
132✔
680
            $feedbackxml = $format->getpath($part, ['#', 'incorrectfeedback', 0], []);
132✔
681
            $question->partincorrectfb[$i] = $format->import_text_with_files(
132✔
682
                $feedbackxml,
132✔
683
                [],
132✔
684
                '',
132✔
685
                $format->get_format($question->questiontextformat),
132✔
686
            );
132✔
687
        }
688

689
        // Make the defaultmark consistent if not specified.
690
        $question->defaultmark = array_sum($question->answermark ?? []);
143✔
691

692
        return $question;
143✔
693
    }
694

695
    /**
696
     * Exports the question to Moodle XML format.
697
     *
698
     * @param object $question question to be exported into XML format
699
     * @param qformat_xml $format format class exporting the question
700
     * @param object $extra extra information (not required for exporting this question in this format)
701
     * @return string containing the question data in XML format
702
     */
703
    public function export_to_xml($question, qformat_xml $format, $extra = null) {
704
        $output = '';
66✔
705
        $contextid = $question->contextid;
66✔
706
        $output .= $format->write_combined_feedback($question->options, $question->id, $question->contextid);
66✔
707

708
        // Get the extra fields we have for our question type. Drop the first entry, because
709
        // it contains the table name.
710
        $extraquestionfields = $this->extra_question_fields();
66✔
711
        array_shift($extraquestionfields);
66✔
712
        foreach ($extraquestionfields as $extrafield) {
66✔
713
            $output .= "<$extrafield>" . $format->writetext($question->options->$extrafield) . "</$extrafield>\n";
66✔
714
        }
715

716
        $fs = get_file_storage();
66✔
717
        foreach ($question->options->answers as $part) {
66✔
718
            $output .= "<answers>\n";
66✔
719
            $output .= " <partindex>\n  " . $format->writetext($part->partindex) . " </partindex>\n";
66✔
720

721
            foreach (self::PART_BASIC_FIELDS as $tag) {
66✔
722
                $output .= " <$tag>\n  " . $format->writetext($part->$tag) . " </$tag>\n";
66✔
723
            }
724

725
            $subqfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'answersubqtext', $part->id);
66✔
726
            $subqtextformat = $format->get_format($part->subqtextformat);
66✔
727
            $output .= " <subqtext format=\"$subqtextformat\">\n";
66✔
728
            $output .= $format->writetext($part->subqtext);
66✔
729
            $output .= $format->write_files($subqfiles);
66✔
730
            $output .= " </subqtext>\n";
66✔
731

732
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'answerfeedback', $part->id);
66✔
733
            $feedbackformat = $format->get_format($part->feedbackformat);
66✔
734
            $output .= " <feedback format=\"$feedbackformat\">\n";
66✔
735
            $output .= $format->writetext($part->feedback);
66✔
736
            $output .= $format->write_files($fbfiles);
66✔
737
            $output .= " </feedback>\n";
66✔
738

739
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'partcorrectfb', $part->id);
66✔
740
            $feedbackformat = $format->get_format($part->partcorrectfbformat);
66✔
741
            $output .= " <correctfeedback format=\"$feedbackformat\">\n";
66✔
742
            $output .= $format->writetext($part->partcorrectfb);
66✔
743
            $output .= $format->write_files($fbfiles);
66✔
744
            $output .= " </correctfeedback>\n";
66✔
745

746
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'partpartiallycorrectfb', $part->id);
66✔
747
            $feedbackformat = $format->get_format($part->partpartiallycorrectfbformat);
66✔
748
            $output .= " <partiallycorrectfeedback format=\"$feedbackformat\">\n";
66✔
749
            $output .= $format->writetext($part->partpartiallycorrectfb);
66✔
750
            $output .= $format->write_files($fbfiles);
66✔
751
            $output .= " </partiallycorrectfeedback>\n";
66✔
752

753
            $fbfiles = $fs->get_area_files($contextid, 'qtype_formulas', 'partincorrectfb', $part->id);
66✔
754
            $feedbackformat = $format->get_format($part->partincorrectfbformat);
66✔
755
            $output .= " <incorrectfeedback format=\"$feedbackformat\">\n";
66✔
756
            $output .= $format->writetext($part->partincorrectfb);
66✔
757
            $output .= $format->write_files($fbfiles);
66✔
758
            $output .= " </incorrectfeedback>\n";
66✔
759

760
            $output .= "</answers>\n";
66✔
761
        }
762

763
        return $output;
66✔
764
    }
765

766
    /**
767
     * Check if part placeholders are correctly formatted and unique and if each
768
     * placeholder appears exactly once in the main question text.
769
     *
770
     * @param string $questiontext main question text
771
     * @param object[] $parts data relative to each part, coming from the edit form
772
     * @return array $errors possible error messages for each part's placeholder field
773
     */
774
    public function check_placeholders(string $questiontext, array $parts): array {
775
        // Store possible error messages for every part.
776
        $errors = [];
2,101✔
777

778
        // List of placeholders in order to spot duplicates.
779
        $knownplaceholders = [];
2,101✔
780

781
        foreach ($parts as $i => $part) {
2,101✔
782
            // No error if part's placeholder is empty.
783
            if (empty($part->placeholder)) {
2,101✔
784
                continue;
1,991✔
785
            }
786

787
            $errormsgs = [];
110✔
788

789
            // Maximal length for placeholders is limited to 40.
790
            if (strlen($part->placeholder) > 40) {
110✔
791
                $errormsgs[] = get_string('error_placeholder_too_long', 'qtype_formulas');
22✔
792
            }
793
            // Placeholders must start with # and contain only alphanumeric characters or underscores.
794
            if (!preg_match('/^#\w+$/', $part->placeholder)) {
110✔
795
                $errormsgs[] = get_string('error_placeholder_format', 'qtype_formulas');
22✔
796
            }
797
            // Placeholders must be unique.
798
            if (in_array($part->placeholder, $knownplaceholders)) {
110✔
799
                $errormsgs[] = get_string('error_placeholder_sub_duplicate', 'qtype_formulas');
22✔
800
            }
801
            // Add this placeholder to the list of known values.
802
            $knownplaceholders[] = $part->placeholder;
110✔
803

804
            // Each placeholder must appear exactly once in the main question text.
805
            $count = substr_count($questiontext, "{{$part->placeholder}}");
110✔
806
            if ($count < 1) {
110✔
807
                $errormsgs[] = get_string('error_placeholder_missing', 'qtype_formulas');
11✔
808
            }
809
            if ($count > 1) {
110✔
810
                $errormsgs[] = get_string('error_placeholder_main_duplicate', 'qtype_formulas');
22✔
811
            }
812

813
            // Concatenate all error messages and store them, so they can be shown in the edit form.
814
            // The corresponding field's name is 'placeholder[...]', so we use that as the array key.
815
            if (!empty($errormsgs)) {
110✔
816
                $errors["placeholder[$i]"] = implode(' ', $errormsgs);
55✔
817
            }
818
        }
819

820
        // Return the errors. The array will be empty, if everything was fine.
821
        return $errors;
2,101✔
822
    }
823

824
    /**
825
     * For each part, check that all required fields have been filled and that they are valid.
826
     * Return the filtered data for all parts.
827
     *
828
     * @param object $data data from the edit form (or an import)
829
     * @return object stdClass with properties 'errors' (for errors) and 'parts' (array of stdClass, data for each part)
830
     */
831
    public function check_and_filter_parts(object $data): object {
832
        // This function is also called when importing a question.
833
        $isfromimport = property_exists($data, 'import_process');
2,277✔
834

835
        $partdata = [];
2,277✔
836
        $errors = [];
2,277✔
837
        $hasoneanswer = false;
2,277✔
838

839
        // Note: If we are importing and the data is damaged, there might be no parts at all. Hence,
840
        // it is safer to use the ?? operator.
841
        foreach (array_keys($data->answermark ?? []) as $i) {
2,277✔
842
            // The answermark must not be empty or 0.
843
            $nomark = empty(trim($data->answermark[$i]));
2,277✔
844

845
            // For answers, zero is not nothing... Note that PHP < 8.0 does not consider '1 ' as numeric,
846
            // so we trim first. PHP 8.0+ makes no difference between leading or trailing whitespace.
847
            $noanswer = empty(trim($data->answer[$i])) && !is_numeric(trim($data->answer[$i]));
2,277✔
848
            if ($noanswer === false) {
2,277✔
849
                $hasoneanswer = true;
2,211✔
850
            }
851

852
            // For maximum backwards compatibility, we consider a part as being "empty", if
853
            // has no question text (subqtext), no general feedback (combined feedback was
854
            // probably not taken into account at first, because it was added later) and no
855
            // local vars.
856
            // Note that data from the editors is stored in an array with the keys text, format and itemid.
857
            // Also note that local vars are entered in a textarea (and not an editor) and are PARAM_RAW_TRIMMED.
858
            $noparttext = strlen(trim($data->subqtext[$i]['text'])) === 0;
2,277✔
859
            $nogeneralfb = strlen(trim($data->subqtext[$i]['text'])) === 0;
2,277✔
860
            $nolocalvars = strlen(trim($data->vars1[$i])) === 0;
2,277✔
861
            $emptypart = $noparttext && $nogeneralfb && $nolocalvars;
2,277✔
862

863
            // Having no answermark is only allowed if the part is "empty" AND if there is no answer.
864
            if ($nomark && !($emptypart && $noanswer)) {
2,277✔
865
                $errors["answermark[$i]"] = get_string('error_mark', 'qtype_formulas');
55✔
866
            }
867
            // On the other hand, having no answer is allowed for "empty" parts even if they
868
            // do have an answermark. We do this, because the answermark field is set by default when
869
            // the user clicks the "Blanks for 2 more parts" button. But if they do that by accident
870
            // and don't want those parts, they should not have to worry about them.
871
            if ($noanswer && !$emptypart) {
2,277✔
872
                $errors["answer[$i]"] = get_string('error_answer_missing', 'qtype_formulas');
33✔
873
            }
874

875
            // No need to validate the remainder of this part if there is no answer or no mark.
876
            if ($noanswer || $nomark) {
2,277✔
877
                continue;
154✔
878
            }
879

880
            // The mark must be strictly positive.
881
            if (floatval($data->answermark[$i]) <= 0) {
2,200✔
882
                $errors["answermark[$i]"] = get_string('error_mark', 'qtype_formulas');
22✔
883
            }
884

885
            // The grading criterion must not be empty. Also, if there is no grading criterion, it does
886
            // not make sense to continue the validation.
887
            if (empty(trim($data->correctness[$i])) && !is_numeric(trim($data->correctness[$i]))) {
2,200✔
888
                $errors["correctness[$i]"] = get_string('error_criterion_empty', 'qtype_formulas');
11✔
889
                continue;
11✔
890
            }
891

892
            // Create a stdClass for each part, start by setting the questionid property which is
893
            // common for all parts.
894
            $partdata[$i] = (object)['questionid' => $data->id];
2,189✔
895
            // Set the basic fields, e.g. mark, placeholder or definition of local variables.
896
            foreach (self::PART_BASIC_FIELDS as $field) {
2,189✔
897
                // In the edit form, the part's 'unitpenalty' and 'ruleid' are set via the global options
898
                // 'globalunitpenalty' and 'globalruleid'. When importing a question, they do not need
899
                // special treatment, because they are already stored with the part. Also, all other fields
900
                // are submitted by part and do not need special treatment either.
901
                if (in_array($field, ['ruleid', 'unitpenalty']) && !$isfromimport) {
2,189✔
902
                    $partdata[$i]->$field = trim($data->{'global' . $field});
2,145✔
903
                } else {
904
                    $partdata[$i]->$field = trim($data->{$field}[$i]);
2,189✔
905
                }
906
            }
907

908
            // The various texts are stored as arrays with the keys 'text', 'format' and (if coming from
909
            // the edit form) 'itemid'. We can just copy that over.
910
            $partdata[$i]->subqtext = $data->subqtext[$i];
2,189✔
911
            $partdata[$i]->feedback = $data->feedback[$i];
2,189✔
912
            $partdata[$i]->partcorrectfb = $data->partcorrectfb[$i];
2,189✔
913
            $partdata[$i]->partpartiallycorrectfb = $data->partpartiallycorrectfb[$i];
2,189✔
914
            $partdata[$i]->partincorrectfb = $data->partincorrectfb[$i];
2,189✔
915
        }
916

917
        // If no part has survived the validation, we need to output an error message. There are three
918
        // reasons why a part's validation can fail:
919
        // (a) there is no answermark
920
        // (b) there is no answer
921
        // (c) there is no grading criterion
922
        // If all parts are left empty, they will fail for case (a) and will not have an error message
923
        // attached to the answer field (answer can be empty if part is empty). In that case, we need
924
        // to output an error to the first answer field (and this one only) saying that at least one
925
        // answer is needed. We do not, however, add that error if the first part failed for case (b) and
926
        // thus already has an error message there.
927
        if (count($partdata) === 0 && $hasoneanswer === false) {
2,277✔
928
            if (empty($errors['answer[0]'])) {
66✔
929
                $errors['answer[0]'] = get_string('error_no_answer', 'qtype_formulas');
55✔
930
            }
931
            // If the answermark was left empty or filled with rubbish, the parameter filtering
932
            // will have changed the value to 0, which is not a valid value. If, in addition,
933
            // the part was otherwise empty, that will not have triggered an error message so far,
934
            // because it might have been on purpose (to delete the unused part). But now that
935
            // there seems to be no part left, we should add an error message to the field.
936
            if (empty($data->answermark[0])) {
66✔
937
                $errors['answermark[0]'] = get_string('error_mark', 'qtype_formulas');
33✔
938
            }
939
        }
940

941
        return (object)['errors' => $errors, 'parts' => $partdata];
2,277✔
942
    }
943

944
    /**
945
     * Check the data from the edit form (or an XML import): parts, answer box placeholders,
946
     * part placeholders and definitions of variables and expressions. At the same time, calculate
947
     * the number of expected answers for every part.
948
     *
949
     * @param object $data
950
     * @return object
951
     */
952
    public function validate(object $data): object {
953
        // Collect all error messages in an associative array of the form 'fieldname' => 'error'.
954
        $errors = [];
2,277✔
955

956
        // The fields 'globalunitpenalty' and 'globalruleid' must be validated separately,
957
        // because they are defined at the question level, even though they affect the parts.
958
        // If we are importing a question, those fields will not be present, because the values
959
        // are already stored with the parts.
960
        $isfromimport = property_exists($data, 'import_process');
2,277✔
961
        if (!$isfromimport) {
2,277✔
962
            $errors += $this->validate_global_unit_fields($data);
2,211✔
963
        }
964

965
        // Check the parts. We get a stdClass with the properties 'errors' (a possibly empty array)
966
        // and 'parts' (an array of stdClass objects, one per part).
967
        $partcheckresult = $this->check_and_filter_parts($data);
2,277✔
968
        $errors += $partcheckresult->errors;
2,277✔
969

970
        // If the basic check failed, we abort and output the error message, because the errors
971
        // might cause other errors downstream.
972
        if (!empty($errors)) {
2,277✔
973
            return (object)['errors' => $errors, 'answers' => null];
187✔
974
        }
975

976
        // From now on, we continue with the checked and filtered parts.
977
        $parts = $partcheckresult->parts;
2,090✔
978

979
        // Make sure that answer box placeholders (if used) are unique for each part.
980
        foreach ($parts as $i => $part) {
2,090✔
981
            try {
982
                formulas_part::scan_for_answer_boxes($part->subqtext['text'], true);
2,090✔
983
            } catch (Exception $e) {
11✔
984
                $errors["subqtext[$i]"] = $e->getMessage();
11✔
985
            }
986
        }
987

988
        // Separately validate the part placeholders. If we are importing, the question text
989
        // will be a string. If the data comes from the edit from, it is in the editor's
990
        // array structure (text, format, itemid).
991
        $errors += $this->check_placeholders(
2,090✔
992
            is_array($data->questiontext) ? $data->questiontext['text'] : $data->questiontext,
2,090✔
993
            $parts
2,090✔
994
        );
2,090✔
995

996
        // Finally, check definition of variables (local, grading), various expressions
997
        // depending on those variables (model answers, correctness criterion) and unit
998
        // stuff. This check also allows us to calculate the number of answers for each part,
999
        // a value that we store as 'numbox'.
1000
        $evaluationresult = $this->check_variables_and_expressions($data, $parts, $isfromimport);
2,090✔
1001
        $errors += $evaluationresult->errors;
2,090✔
1002
        $parts = $evaluationresult->parts;
2,090✔
1003

1004
        return (object)['errors' => $errors, 'answers' => $parts];
2,090✔
1005
    }
1006

1007
    /**
1008
     * This function is called during the validation process to validate the special fields
1009
     * 'globalunitpenalty' and 'globalruleid'. Both fields are used as a single option to set
1010
     * the unit penalty and the unit conversion rules for all parts of a question.
1011
     *
1012
     * @param object $data form data to be validated
1013
     * @return array array containing error messages or empty array if no error
1014
     */
1015
    private function validate_global_unit_fields(object $data): array {
1016
        $errors = [];
2,211✔
1017

1018
        if ($data->globalunitpenalty < 0 || $data->globalunitpenalty > 1) {
2,211✔
1019
            $errors['globalunitpenalty'] = get_string('error_unitpenalty', 'qtype_formulas');
22✔
1020
        }
1021

1022
        // If the globalruleid field is missing, that means the request or the form has
1023
        // been modified by the user. In that case, we set the id to the invalid value -1
1024
        // to simplify the code for the upcoming steps.
1025
        if (!isset($data->globalruleid)) {
2,211✔
1026
            $data->globalruleid = -1;
11✔
1027
        }
1028

1029
        // Finally, check the global setting for the basic conversion rules. We only check this
1030
        // once, because it is the same for all parts.
1031
        $conversionrules = new unit_conversion_rules();
2,211✔
1032
        $entry = $conversionrules->entry($data->globalruleid);
2,211✔
1033
        if ($entry === null || $entry[1] === null) {
2,211✔
1034
            $errors['globalruleid'] = get_string('error_ruleid', 'qtype_formulas');
11✔
1035
        } else {
1036
            $unitcheck = new answer_unit_conversion();
2,200✔
1037
            $unitcheck->assign_default_rules($data->globalruleid, $entry[1]);
2,200✔
1038
            try {
1039
                $unitcheck->reparse_all_rules();
2,200✔
1040
            } catch (Exception $e) {
×
1041
                // This can only happen if the user has modified (and screwed up) conversion_rules.php.
1042
                // TODO: When refactoring the unit stuff, user-defined rules must be written via the
1043
                // settings page and validated there, the user should not be forced to modify the PHP files,
1044
                // also because they will be overwritten on every update.
1045
                $errors['globalruleid'] = $e->getMessage();
×
1046
            }
1047
        }
1048

1049
        return $errors;
2,211✔
1050
    }
1051

1052
    /**
1053
     * Check definition of variables (local vars, grading vars), various expressions
1054
     * like model answers or correctness criterion and unit stuff. At the same time,
1055
     * calculate the number of answers boxes (to be stored in part->numbox) once the
1056
     * model answers are evaluated. Possible errors are returned in the 'errors' property
1057
     * of the return object. The updated part data (now containing the numbox value)
1058
     * is in the 'parts' property, as an array of objects (one object per part).
1059
     *
1060
     * @param object $data
1061
     * @param object[] $parts
1062
     * @param bool $fromimport whether the check is performed during an import process
1063
     * @return object stdClass with 'errors' and 'parts'
1064
     */
1065
    public function check_variables_and_expressions(object $data, array $parts, bool $fromimport = false): object {
1066
        // Collect all errors.
1067
        $errors = [];
2,090✔
1068

1069
        // Check random variables. If there is an error, we do not continue, because
1070
        // other variables or answers might depend on these definitions.
1071
        try {
1072
            $randomparser = new random_parser($data->varsrandom);
2,090✔
1073
            $evaluator = new evaluator();
2,068✔
1074
            $evaluator->evaluate($randomparser->get_statements());
2,068✔
1075
            $evaluator->instantiate_random_variables();
2,068✔
1076
        } catch (Exception $e) {
22✔
1077
            $errors['varsrandom'] = $e->getMessage();
22✔
1078
            return (object)['errors' => $errors, 'parts' => $parts];
22✔
1079
        }
1080

1081
        // Check global variables. If there is an error, we do not continue, because
1082
        // other variables or answers might depend on these definitions.
1083
        try {
1084
            $globalparser = new parser($data->varsglobal, $randomparser->export_known_variables());
2,068✔
1085
            $evaluator->evaluate($globalparser->get_statements());
2,057✔
1086
        } catch (Exception $e) {
11✔
1087
            $errors['varsglobal'] = $e->getMessage();
11✔
1088
            return (object)['errors' => $errors, 'parts' => $parts];
11✔
1089
        }
1090

1091
        // Check local variables, model answers and grading criterion for each part.
1092
        foreach ($parts as $i => $part) {
2,057✔
1093
            $partevaluator = clone $evaluator;
2,057✔
1094
            $knownvars = $partevaluator->export_variable_list();
2,057✔
1095

1096
            // Validate the local variables for this part. In case of an error, skip the
1097
            // rest of the part, because there might be dependencies.
1098
            $partparser = null;
2,057✔
1099
            if (!empty($part->vars1)) {
2,057✔
1100
                try {
1101
                    $partparser = new parser($part->vars1, $knownvars);
165✔
1102
                    $partevaluator->evaluate($partparser->get_statements());
154✔
1103
                } catch (Exception $e) {
22✔
1104
                    $errors["vars1[$i]"] = $e->getMessage();
22✔
1105
                    continue;
22✔
1106
                }
1107
            }
1108

1109
            // If there were no local variables, the partparser has not been initialized yet.
1110
            // Otherwise, we export its known variables.
1111
            if ($partparser !== null) {
2,035✔
1112
                $knownvars = $partparser->export_known_variables();
143✔
1113
            }
1114

1115
            // Check whether the part uses the algebraic answer type.
1116
            $isalgebraic = $part->answertype == self::ANSWER_TYPE_ALGEBRAIC;
2,035✔
1117

1118
            // Try evaluating the model answers. If this fails, don't validate the rest of
1119
            // this part, because there are dependencies.
1120
            try {
1121
                // If (and only if) the answer is algebraic, the answer parser should
1122
                // interpret ^ as **. The last argument tells the answer parser that we are
1123
                // checking model answers, i. e. the prefix operator is allowed.
1124
                $answerparser = new answer_parser($part->answer, $knownvars, $isalgebraic, true);
2,035✔
1125
                // If the user enters a comment sign in the model answer, it is not technically empty,
1126
                // but it will be parsed as an empty expression. We catch this here and make use of
1127
                // the catch block to pass an error message.
1128
                if (empty($answerparser->get_statements())) {
2,002✔
1129
                    throw new Exception(get_string('error_model_answer_no_content', 'qtype_formulas'));
11✔
1130
                }
1131
                $modelanswers = $partevaluator->evaluate($answerparser->get_statements())[0];
1,991✔
1132
            } catch (Exception $e) {
66✔
1133
                // If the answer type is algebraic, the model answer field must contain one string (with quotes)
1134
                // or an array of strings. Thus, evaluation of the field's content as done above cannot fail,
1135
                // unless that syntax constraint has not been respected by the user.
1136
                if ($isalgebraic) {
66✔
1137
                    $errors["answer[$i]"] = get_string('error_string_for_algebraic_formula', 'qtype_formulas');
11✔
1138
                } else {
1139
                    $errors["answer[$i]"] = $e->getMessage();
55✔
1140
                }
1141
                continue;
66✔
1142
            }
1143

1144
            // Check the model answer. If it is a LIST, make an array out of the individual tokens. If it is
1145
            // a single token, wrap it into a single-element array.
1146
            // Note: If we have e.g. the (global or local) variable 'a=[1,2,3]' and the answer is 'a',
1147
            // this will be considered as three answers 1, 2 and 3. We must maintain that interpretation
1148
            // for backwards compatibility.
1149
            if ($modelanswers->type === token::LIST) {
1,969✔
1150
                $modelanswers = $modelanswers->value;
275✔
1151
            } else {
1152
                $modelanswers = [$modelanswers];
1,694✔
1153
            }
1154

1155
            // As $modelanswers is now an array, we can iterate over all answers. If the answer type is
1156
            // "algebraic formula", they must all be strings. Otherwise, they must all be numbers or
1157
            // at least numeric strings.
1158
            foreach ($modelanswers as $answer) {
1,969✔
1159
                if ($isalgebraic && ($answer->type !== token::STRING && $answer->type !== token::EMPTY)) {
1,969✔
1160
                    $errors["answer[$i]"] = get_string('error_string_for_algebraic_formula', 'qtype_formulas');
33✔
1161
                    continue;
33✔
1162
                }
1163
                if (!$isalgebraic && !($answer->type === token::EMPTY || $answer->type === token::NUMBER || is_numeric($answer->value))) {
1,936✔
1164
                    $errors["answer[$i]"] = get_string('error_number_for_numeric_answertypes', 'qtype_formulas');
11✔
1165
                    continue;
11✔
1166
                }
1167
            }
1168
            // Finally, we convert the array of tokens into an array of literals.
1169
            // FIXME: remove this
1170
            // $modelanswers = token::unpack($modelanswers);
1171

1172
            // Now that we know the model answers, we can set the $numbox property for the part,
1173
            // i. e. the number of answer boxes that are to be shown.
1174
            $part->numbox = count($modelanswers);
1,969✔
1175

1176
            // If the answer type is algebraic, we must now try to do algebraic evaluation of each answer
1177
            // to check for bad formulas.
1178
            if ($isalgebraic) {
1,969✔
1179
                foreach ($modelanswers as $k => $answertoken) {
209✔
1180
                    // If it is the $EMPTY token, we have nothing to do.
1181
                    if ($answertoken->type === token::EMPTY) {
209✔
NEW
1182
                        continue;
×
1183
                    }
1184
                    // Evaluating the string should give us a numeric value.
1185
                    try {
1186
                        $result = $partevaluator->calculate_algebraic_expression($answertoken->value);
209✔
1187
                    } catch (Exception $e) {
22✔
1188
                        $a = (object)[
22✔
1189
                            // Answers are zero-indexed, but users normally count from 1.
1190
                            'answerno' => $k + 1,
22✔
1191
                            // The error message may contain line and column numbers, but they don't make
1192
                            // sense in this context, so we'd rather remove them.
1193
                            'message' => preg_replace('/([^:]+:)([^:]+:)/', '', $e->getMessage()),
22✔
1194
                        ];
22✔
1195
                        $errors["answer[$i]"] = get_string('error_in_answer', 'qtype_formulas', $a);
22✔
1196
                        break;
22✔
1197
                    }
1198
                }
1199
            }
1200

1201
            // If there was an error, we do not continue the validation, because the answers are going to
1202
            // be used for the next steps.
1203
            if (!empty($errors["answer[$i]"])) {
1,969✔
1204
                continue;
66✔
1205
            }
1206

1207
            // In order to prepare the grading variables, we need to have the special vars like
1208
            // _a and _r or _0, _1, ... or _err and _relerr. We will create a dummy part and use it
1209
            // as our worker. Note that the result will automatically flow back into $partevaluator,
1210
            // because the assignment is by reference only.
1211
            $dummypart = new formulas_part();
1,903✔
1212
            $dummypart->answertype = $part->answertype;
1,903✔
1213
            $dummypart->answer = $part->answer;
1,903✔
1214
            $dummypart->evaluator = $partevaluator;
1,903✔
1215
            try {
1216
                // As we are using the model answers, we must set the third parameter to TRUE, because
1217
                // there might be a PREFIX operator. This is important for answer type algebraic formula.
1218
                $dummypart->add_special_variables($dummypart->get_evaluated_answers(), 1, true);
1,903✔
1219
            } catch (Throwable $e) {
11✔
1220
                // If the last step failed (e. g. because the model answer to an algebraic formula question
1221
                // contained a PREFIX operator), we attach the message to the answer field and stop
1222
                // further validation steps.
1223
                $errors["answer[$i]"] = $e->getMessage();
11✔
1224
                continue;
11✔
1225
            }
1226

1227
            // Validate grading variables.
1228
            if (!empty($part->vars2)) {
1,892✔
1229
                try {
1230
                    $partparser = new parser($part->vars2, $knownvars);
44✔
1231
                    $partevaluator->evaluate($partparser->get_statements());
33✔
1232
                } catch (Exception $e) {
22✔
1233
                    $errors["vars2[$i]"] = $e->getMessage();
22✔
1234
                    continue;
22✔
1235
                }
1236

1237
                // Update the list of known variables.
1238
                $knownvars = $partparser->export_known_variables();
22✔
1239
            }
1240

1241
            // Check grading criterion.
1242
            $grade = 0;
1,870✔
1243
            try {
1244
                $partparser = new parser($part->correctness, $knownvars);
1,870✔
1245
                $result = $partevaluator->evaluate($partparser->get_statements());
1,870✔
1246
                $num = count($result);
1,848✔
1247
                if ($num > 1) {
1,848✔
1248
                    $errors["correctness[$i]"] = get_string('error_grading_single_expression', 'qtype_formulas', $num);
11✔
1249
                }
1250
                $grade = $result[0]->value;
1,848✔
1251
            } catch (Exception $e) {
22✔
1252
                $message = $e->getMessage();
22✔
1253
                // If we are working with the answer type "algebraic formula" and there was an error
1254
                // during evaluation of the grading criterion *and* the error message contains '_relerr',
1255
                // we change the message, because it is save to assume that the teacher tried to use
1256
                // relative error which is not supported with that answer type.
1257
                if ($isalgebraic && strpos($message, '_relerr') !== false) {
22✔
1258
                    $message = get_string('error_algebraic_relerr', 'qtype_formulas');
11✔
1259
                }
1260
                $errors["correctness[$i]"] = $message;
22✔
1261
                continue;
22✔
1262
            }
1263

1264
            // We used the model answers, so the grading criterion should always evaluate to 1 (or more).
1265
            // This check is omitted when importing questions, because it did not exist in legacy versions,
1266
            // so teachers might have questions with "wrong" model answers and their import will fail.
1267
            $lenientimport = get_config('qtype_formulas', 'lenientimport');
1,848✔
1268
            $checkthis = !$lenientimport || !$fromimport;
1,848✔
1269
            if ($checkthis && $grade < 0.999) {
1,848✔
1270
                $errors["correctness[$i]"] = get_string('error_grading_not_one', 'qtype_formulas', $grade);
33✔
1271
            }
1272

1273
            // Instantiate toolkit class for units.
1274
            $unitcheck = new answer_unit_conversion();
1,848✔
1275

1276
            // If a unit has been provided, check whether it can be parsed.
1277
            if (!empty($part->postunit)) {
1,848✔
1278
                try {
1279
                    $unitcheck->parse_targets($part->postunit);
506✔
1280
                } catch (Exception $e) {
11✔
1281
                    $errors["postunit[$i]"] = get_string('error_unit', 'qtype_formulas');
11✔
1282
                }
1283
            }
1284

1285
            // If provided by the user, check the additional conversion rules. We do validate those
1286
            // rules even if the unit has not been set, because we would not want to have invalid stuff
1287
            // in the database.
1288
            if (!empty($part->otherrule)) {
1,848✔
1289
                try {
1290
                    $unitcheck->assign_additional_rules($part->otherrule);
11✔
1291
                    $unitcheck->reparse_all_rules();
11✔
1292
                } catch (Exception $e) {
11✔
1293
                    $errors["otherrule[$i]"] = get_string('error_rule', 'qtype_formulas');
11✔
1294
                }
1295
            }
1296
        }
1297

1298
        return (object)['errors' => $errors, 'parts' => $parts];
2,057✔
1299
    }
1300

1301
    /**
1302
     * Reorder the parts according to the order of placeholders in main question text.
1303
     * Note: the check_placeholder() function should be called before.
1304
     *
1305
     * @param string $questiontext main question text, containing the placeholders
1306
     * @param object[] $parts part data
1307
     * @return object[] sorted parts
1308
     */
1309
    public function reorder_parts(string $questiontext, array $parts): array {
1310
        // Scan question text for part placeholders; $matches[1] will contain a list of
1311
        // the matches in the order of appearance.
1312
        $matches = [];
1,694✔
1313
        preg_match_all('/\{(#\w+)\}/', $questiontext, $matches);
1,694✔
1314

1315
        $ordered = [];
1,694✔
1316

1317
        // First, add the parts with a placeholder, ordered by their appearance.
1318
        foreach ($parts as $part) {
1,694✔
1319
            $newindex = array_search($part->placeholder, $matches[1]);
1,694✔
1320
            if ($newindex !== false) {
1,694✔
1321
                $ordered[$newindex] = $part;
88✔
1322
            }
1323
        }
1324

1325
        // Now, append all remaining parts that do not have a placeholder.
1326
        foreach ($parts as $part) {
1,694✔
1327
            if (empty($part->placeholder)) {
1,694✔
1328
                $ordered[] = $part;
1,617✔
1329
            }
1330
        }
1331

1332
        // Sort the parts by their index and assign result.
1333
        ksort($ordered);
1,694✔
1334

1335
        return $ordered;
1,694✔
1336
    }
1337

1338
    /**
1339
     * Similar to Moodle's format_float() function, this function will output a float number
1340
     * with the locale's decimal separator. It checks the admin setting (allowdecimalcomma)
1341
     * before doing so; if the decimal comma is not activated, the point will be used in
1342
     * all cases. We are not using the library function, because it can lead to unnecessary
1343
     * trailing zeroes. When using the function for LaTeX output, it will wrap the comma in
1344
     * curly braces for correct spacing.
1345
     *
1346
     * @param float|string $float a number or numeric string to be formatted
1347
     * @param bool $forlatex whether the output is for LaTeX code
1348
     * @return string
1349
     */
1350
    public static function format_float($float, bool $forlatex = false): string {
1351
        // If use of decimal comma (or other local decimal separator) is not allowed, we
1352
        // return the number as-is.
1353
        if (!get_config('qtype_formulas', 'allowdecimalcomma')) {
2,013✔
1354
            return strval($float);
2,013✔
1355
        }
1356

1357
        // Get the correct decimal separator according to the user's locale. If necessary,
1358
        // put braces around it.
1359
        $decsep = get_string('decsep', 'langconfig');
22✔
1360
        if ($forlatex && $decsep === ',') {
22✔
1361
            $decsep = '{' . $decsep . '}';
11✔
1362
        }
1363

1364
        return str_replace('.', $decsep, strval($float));
22✔
1365
    }
1366
}
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