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

FormulasQuestion / moodle-qtype_formulas / 13200038469

07 Feb 2025 12:40PM UTC coverage: 76.583% (+1.5%) from 75.045%
13200038469

Pull #62

github

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

2517 of 3116 new or added lines in 22 files covered. (80.78%)

146 existing lines in 6 files now uncovered.

2976 of 3886 relevant lines covered (76.58%)

431.97 hits per line

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

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

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

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

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

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

110
        return array_keys($parts);
187✔
111
    }
112

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

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

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

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

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

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

161
        $this->delete_files_in_combined_feedback($questionid, $contextid);
170✔
162
        $this->delete_files_in_hints($questionid, $contextid);
170✔
163

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

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

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

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

208
        parent::get_question_options($question);
136✔
209

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

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

218
        return true;
136✔
219
    }
220

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

394
        // Still here? Then we must remove remaining parts and their files (if there are), because the
395
        // user seems to have deleted them in the form.
NEW
396
        $fs = get_file_storage();
×
NEW
397
        foreach ($existingparts as $leftover) {
×
NEW
398
            $areas = ['answersubqtext', 'answerfeedback', 'partcorrectfb', 'partpartiallycorrectfb', 'partincorrectfb'];
×
NEW
399
            foreach ($areas as $area) {
×
NEW
400
                $fs->delete_area_files($context->id, 'qtype_formulas', $area, $leftover->id);
×
401
            }
402
            try {
NEW
403
                $DB->delete_records('qtype_formulas_answers', ['id' => $leftover->id]);
×
NEW
404
            } catch (Exception $e) {
×
NEW
405
                return (object)['error' => get_string('error_db_delete', 'qtype_formulas', 'qtype_formulas_answers')];
×
406
            }
407
        }
408
    }
409

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

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

432
        // Preparation work is done, let the parent method do the rest.
433
        return parent::save_question($question, $formdata);
238✔
434
    }
435

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

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

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

463
        // Finally, remove the related parts from the qtype_formulas_answers table.
464
        $DB->delete_records('qtype_formulas_answers', ['questionid' => $questionid]);
170✔
465
    }
466

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

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

499
        // Add the remainder of the question text after the last part; this might be an empty string.
500
        $fragments[] = $questiontext;
204✔
501

502
        return $fragments;
204✔
503
    }
504

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

516
        // First, copy some data for the main question.
517
        $question->varsrandom = $questiondata->options->varsrandom;
170✔
518
        $question->varsglobal = $questiondata->options->varsglobal;
170✔
519
        $question->answernumbering = $questiondata->options->answernumbering;
170✔
520
        $question->numparts = $questiondata->options->numparts;
170✔
521

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

527
            // Copy the data fields fetched from the DB to the question part object.
528
            foreach ($partdata as $key => $value) {
170✔
529
                $questionpart->{$key} = $value;
170✔
530
            }
531

532
            // And finally store the populated part in the main question instance.
533
            $question->parts[$partdata->partindex] = $questionpart;
170✔
534
        }
535

536
        // Split the main question text into fragments that will later surround the parts' texts.
537
        $question->textfragments = $this->split_questiontext($question->questiontext, $question->parts);
170✔
538

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

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

553
        /** @var qtype_formulas_question $question */
NEW
554
        $question = $this->make_question($questiondata);
×
555

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

NEW
576
        return $responses;
×
577
    }
578

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

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

600
        $question->varsrandom = $format->getpath($xml, ['#', 'varsrandom', 0, '#', 'text', 0, '#'], '', true);
119✔
601
        $question->varsglobal = $format->getpath($xml, ['#', 'varsglobal', 0, '#', 'text', 0, '#'], '', true);
119✔
602
        $question->answernumbering = $format->getpath($xml, ['#', 'answernumbering', 0, '#', 'text', 0, '#'], 'none', true);
119✔
603

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

630
            $subqxml = $format->getpath($part, ['#', 'subqtext', 0], []);
119✔
631
            $question->subqtext[$i] = $format->import_text_with_files($subqxml,
119✔
632
                        [], '', $format->get_format($question->questiontextformat));
119✔
633

634
            $feedbackxml = $format->getpath($part, ['#', 'feedback', 0], []);
119✔
635
            $question->feedback[$i] = $format->import_text_with_files($feedbackxml,
119✔
636
                        [], '', $format->get_format($question->questiontextformat));
119✔
637

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

649
        // Make the defaultmark consistent if not specified.
650
        $question->defaultmark = array_sum($question->answermark);
119✔
651

652
        return $question;
119✔
653
    }
654

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

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

676
        $fs = get_file_storage();
85✔
677
        foreach ($question->options->answers as $part) {
85✔
678
            $output .= "<answers>\n";
85✔
679
            $output .= " <partindex>\n  " . $format->writetext($part->partindex) . " </partindex>\n";
85✔
680

681
            foreach (self::PART_BASIC_FIELDS as $tag) {
85✔
682
                $output .= " <$tag>\n  " . $format->writetext($part->$tag) . " </$tag>\n";
85✔
683
            }
684

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

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

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

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

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

720
            $output .= "</answers>\n";
85✔
721
        }
722

723
        return $output;
85✔
724
    }
725

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

738
        // List of placeholders in order to spot duplicates.
739
        $knownplaceholders = [];
1,241✔
740

741
        foreach ($parts as $i => $part) {
1,241✔
742
            // No error if part's placeholder is empty.
743
            if (empty($part->placeholder)) {
1,241✔
744
                continue;
1,156✔
745
            }
746

747
            $errormsgs = [];
85✔
748

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

764
            // Each placeholder must appear exactly once in the main question text.
765
            $count = substr_count($questiontext, "{{$part->placeholder}}");
85✔
766
            if ($count < 1) {
85✔
767
                $errormsgs[] = get_string('error_placeholder_missing', 'qtype_formulas');
17✔
768
            }
769
            if ($count > 1) {
85✔
770
                $errormsgs[] = get_string('error_placeholder_main_duplicate', 'qtype_formulas');
34✔
771
            }
772

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

780
        // Return the errors. The array will be empty, if everything was fine.
781
        return $errors;
1,241✔
782
    }
783

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

796
        $partdata = [];
1,479✔
797
        $errors = [];
1,479✔
798
        $hasoneanswer = false;
1,479✔
799

800
        foreach (array_keys($data->answermark) as $i) {
1,479✔
801
            // The answermark must not be empty or 0.
802
            $nomark = empty(trim($data->answermark[$i]));
1,479✔
803

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

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

822
            // Having no answermark is only allowed if the part is "empty" AND if there is no answer.
823
            if ($nomark && !($emptypart && $noanswer)) {
1,479✔
824
                $errors["answermark[$i]"] = get_string('error_mark', 'qtype_formulas');
85✔
825
            }
826
            // Similarly, having no answer is only allowed for "empty" parts that do not have an answermark.
827
            if ($noanswer && !($emptypart && $nomark)) {
1,479✔
828
                $errors["answer[$i]"] = get_string('error_answer_missing', 'qtype_formulas');
51✔
829
            }
830

831
            // No need to validate the remainder of this part if there is no answer or no mark.
832
            if ($noanswer || $nomark) {
1,479✔
833
                continue;
187✔
834
            }
835

836
            // The mark must be strictly positive.
837
            if (floatval($data->answermark[$i]) <= 0) {
1,394✔
838
                $errors["answermark[$i]"] = get_string('error_mark', 'qtype_formulas');
34✔
839
            }
840

841
            // The grading criterion must not be empty. Also, if there is no grading criterion, it does
842
            // not make sense to continue the validation.
843
            if (empty(trim($data->correctness[$i]))) {
1,394✔
844
                $errors["correctness[$i]"] = get_string('error_criterion_empty', 'qtype_formulas');
17✔
845
                continue;
17✔
846
            }
847

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

864
            // The various texts are stored as arrays with the keys 'text', 'format' and (if coming from
865
            // the edit form) 'itemid'. We can just copy that over.
866
            $partdata[$i]->subqtext = $data->subqtext[$i];
1,377✔
867
            $partdata[$i]->feedback = $data->feedback[$i];
1,377✔
868
            $partdata[$i]->partcorrectfb = $data->partcorrectfb[$i];
1,377✔
869
            $partdata[$i]->partpartiallycorrectfb = $data->partpartiallycorrectfb[$i];
1,377✔
870
            $partdata[$i]->partincorrectfb = $data->partincorrectfb[$i];
1,377✔
871
        }
872

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

897
        return (object)['errors' => $errors, 'parts' => $partdata];
1,479✔
898
    }
899

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

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

923
        // Check the parts. We get a stdClass with the properties 'errors' (a possibly empty array)
924
        // and 'parts' (an array of stdClass objects, one per part).
925
        $partcheckresult = $this->check_and_filter_parts($data);
1,479✔
926
        $errors += $partcheckresult->errors;
1,479✔
927

928
        // If the basic check failed, we abort and output the error message, because the errors
929
        // might cause other errors downstream.
930
        if (!empty($errors)) {
1,479✔
931
            return (object)['errors' => $errors, 'answers' => null];
255✔
932
        }
933

934
        // From now on, we continue with the checked and filtered parts.
935
        $parts = $partcheckresult->parts;
1,224✔
936

937
        // Make sure that answer box placeholders (if used) are unique for each part.
938
        foreach ($parts as $i => $part) {
1,224✔
939
            try {
940
                qtype_formulas_part::scan_for_answer_boxes($part->subqtext['text'], true);
1,224✔
941
            } catch (Exception $e) {
17✔
942
                $errors["subqtext[$i]"] = $e->getMessage();
17✔
943
            }
944
        }
945

946
        // Separately validate the part placeholders. If we are importing, the question text
947
        // will be a string. If the data comes from the edit from, it is in the editor's
948
        // array structure (text, format, itemid).
949
        $errors += $this->check_placeholders(
1,224✔
950
            is_array($data->questiontext) ? $data->questiontext['text'] : $data->questiontext,
1,224✔
951
            $parts
1,224✔
952
        );
1,224✔
953

954
        // Finally, check definition of variables (local, grading), various expressions
955
        // depending on those variables (model answers, correctness criterion) and unit
956
        // stuff. This check also allows us to calculate the number of answers for each part,
957
        // a value that we store as 'numbox'.
958
        $evaluationresult = $this->check_variables_and_expressions($data, $parts);
1,224✔
959
        $errors += $evaluationresult->errors;
1,224✔
960
        $parts = $evaluationresult->parts;
1,224✔
961

962
        return (object)['errors' => $errors, 'answers' => $parts];
1,224✔
963
    }
964

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

976
        if ($data->globalunitpenalty < 0 || $data->globalunitpenalty > 1) {
1,224✔
977
            $errors['globalunitpenalty'] = get_string('error_unitpenalty', 'qtype_formulas');;
34✔
978
        }
979

980
        // If the globalruleid field is missing, that means the request or the form has
981
        // been modified by the user. In that case, we set the id to the invalid value -1
982
        // to simplify the code for the upcoming steps.
983
        if (!isset($data->globalruleid)) {
1,224✔
984
            $data->globalruleid = -1;
17✔
985
        }
986

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

1007
        return $errors;
1,224✔
1008
    }
1009

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

1026
        // Check random variables. If there is an error, we do not continue, because
1027
        // other variables or answers might depend on these definitions.
1028
        try {
1029
            $randomparser = new random_parser($data->varsrandom);
1,224✔
1030
            $evaluator = new evaluator();
1,190✔
1031
            $evaluator->evaluate($randomparser->get_statements());
1,190✔
1032
            $evaluator->instantiate_random_variables();
1,190✔
1033
        } catch (Exception $e) {
34✔
1034
            $errors['varsrandom'] = $e->getMessage();
34✔
1035
            return (object)['errors' => $errors, 'parts' => $parts];
34✔
1036
        }
1037

1038
        // Check global variables. If there is an error, we do not continue, because
1039
        // other variables or answers might depend on these definitions.
1040
        try {
1041
            $globalparser = new parser($data->varsglobal, $randomparser->export_known_variables());
1,190✔
1042
            $evaluator->evaluate($globalparser->get_statements());
1,173✔
1043
        } catch (Exception $e) {
17✔
1044
            $errors['varsglobal'] = $e->getMessage();
17✔
1045
            return (object)['errors' => $errors, 'parts' => $parts];
17✔
1046
        }
1047

1048
        // Check local variables, model answers and grading criterion for each part.
1049
        foreach ($parts as $i => $part) {
1,173✔
1050
            $partevaluator = clone $evaluator;
1,173✔
1051
            $knownvars = $partevaluator->export_variable_list();
1,173✔
1052

1053
            // Validate the local variables for this part. In case of an error, skip the
1054
            // rest of the part, because there might be dependencies.
1055
            $partparser = null;
1,173✔
1056
            if (!empty($part->vars1)) {
1,173✔
1057
                try {
1058
                    $partparser = new parser($part->vars1, $knownvars);
170✔
1059
                    $partevaluator->evaluate($partparser->get_statements());
153✔
1060
                } catch (Exception $e) {
34✔
1061
                    $errors["vars1[$i]"] = $e->getMessage();
34✔
1062
                    continue;
34✔
1063
                }
1064
            }
1065

1066
            // If there were no local variables, the partparser has not been initialized yet.
1067
            // Otherwise, we export its known variables.
1068
            if ($partparser !== null) {
1,139✔
1069
                $knownvars = $partparser->export_known_variables();
136✔
1070
            }
1071

1072
            // Check whether the part uses the algebraic answer type.
1073
            $isalgebraic = $part->answertype == self::ANSWER_TYPE_ALGEBRAIC;
1,139✔
1074

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

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

1112
            // As $modelanswers is now an array, we can iterate over all answers. If the answer type is
1113
            // "algebraic formula", they must all be strings. Otherwise, they must all be numbers.
1114
            foreach ($modelanswers as $answer) {
1,054✔
1115
                if ($isalgebraic && $answer->type !== token::STRING) {
1,054✔
1116
                    $errors["answer[$i]"] = get_string('error_string_for_algebraic_formula', 'qtype_formulas');
51✔
1117
                    continue;
51✔
1118
                }
1119
                if (!$isalgebraic && $answer->type !== token::NUMBER) {
1,003✔
1120
                    $errors["answer[$i]"] = get_string('error_number_for_numeric_answertypes', 'qtype_formulas');
17✔
1121
                    continue;
17✔
1122
                }
1123
            }
1124
            // Finally, we convert the array of tokens into an array of literals.
1125
            $modelanswers = token::unpack($modelanswers);
1,054✔
1126

1127
            // Now that we know the model answers, we can set the $numbox property for the part,
1128
            // i. e. the number of answer boxes that are to be shown.
1129
            $part->numbox = count($modelanswers);
1,054✔
1130

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

1152
            // If there was an error, we do not continue the validation, because the answers are going to
1153
            // be used for the next steps.
1154
            if (!empty($errors["answer[$i]"])) {
1,054✔
1155
                continue;
102✔
1156
            }
1157

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

1178
            // Validate grading variables.
1179
            if (!empty($part->vars2)) {
935✔
1180
                try {
1181
                    $partparser = new parser($part->vars2, $knownvars);
68✔
1182
                    $partevaluator->evaluate($partparser->get_statements());
51✔
1183
                } catch (Exception $e) {
34✔
1184
                    $errors["vars2[$i]"] = $e->getMessage();
34✔
1185
                    continue;
34✔
1186
                }
1187

1188
                // Update the list of known variables.
1189
                $knownvars = $partparser->export_known_variables();
34✔
1190
            }
1191

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

1215
            // We used the model answers, so the grading criterion should always evaluate to 1 (or more).
1216
            if ($grade < 0.999) {
867✔
1217
                $errors["correctness[$i]"] = get_string('error_grading_not_one', 'qtype_formulas', $grade);
17✔
1218
            }
1219

1220
            // Instantiate toolkit class for units.
1221
            $unitcheck = new answer_unit_conversion();
867✔
1222

1223
            // If a unit has been provided, check whether it can be parsed.
1224
            if (!empty($part->postunit)) {
867✔
1225
                try {
1226
                    $unitcheck->parse_targets($part->postunit);
238✔
1227
                } catch (Exception $e) {
17✔
1228
                    $errors["postunit[$i]"] = get_string('error_unit', 'qtype_formulas');
17✔
1229
                }
1230
            }
1231

1232
            // If provided by the user, check the additional conversion rules. We do validate those
1233
            // rules even if the unit has not been set, because we would not want to have invalid stuff
1234
            // in the database.
1235
            if (!empty($part->otherrule)) {
867✔
1236
                try {
1237
                    $unitcheck->assign_additional_rules($part->otherrule);
17✔
1238
                    $unitcheck->reparse_all_rules();
17✔
1239
                } catch (Exception $e) {
17✔
1240
                    $errors["otherrule[$i]"] = get_string('error_rule', 'qtype_formulas');
17✔
1241
                }
1242
            }
1243
        }
1244

1245
        return (object)['errors' => $errors, 'parts' => $parts];
1,173✔
1246
    }
1247

1248
    /**
1249
     * Reorder the parts according to the order of placeholders in main question text.
1250
     * Note: the check_placeholder() function should be called before.
1251
     *
1252
     * @param string $questiontext main question text, containing the placeholders
1253
     * @param object[] $parts part data
1254
     * @return object[] sorted parts
1255
     */
1256
    public function reorder_parts(string $questiontext, array $parts): array {
1257
        // Scan question text for part placeholders; $matches[1] will contain a list of
1258
        // the matches in the order of appearance.
1259
        $matches = [];
442✔
1260
        preg_match_all('/\{(#\w+)\}/', $questiontext, $matches);
442✔
1261

1262
        $ordered = [];
442✔
1263

1264
        // First, add the parts with a placeholder, ordered by their appearance.
1265
        foreach ($parts as $part) {
442✔
1266
            $newindex = array_search($part->placeholder, $matches[1]);
442✔
1267
            if ($newindex !== false) {
442✔
1268
                $ordered[$newindex] = $part;
51✔
1269
            }
1270
        }
1271

1272
        // Now, append all remaining parts that do not have a placeholder.
1273
        foreach ($parts as $part) {
442✔
1274
            if (empty($part->placeholder)) {
442✔
1275
                $ordered[] = $part;
408✔
1276
            }
1277
        }
1278

1279
        // Sort the parts by their index and assign result.
1280
        ksort($ordered);
442✔
1281

1282
        return $ordered;
442✔
1283
    }
1284
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc