• 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

94.59
/question.php
1
<?php
2
// This file is part of Moodle - https://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <https://www.gnu.org/licenses/>.
16

17
/**
18
 * Question definition class for the Formulas question type.
19
 *
20
 * @copyright 2010-2011 Hon Wai, Lau; 2023 Philipp Imhof
21
 * @author Hon Wai, Lau <lau65536@gmail.com>
22
 * @author Philipp Imhof
23
 * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24
 * @package qtype_formulas
25
 */
26

27

28

29

30
// TODO: rewrite input checker script for student answer and teacher's model answer / unit.
31

32
use qtype_formulas\answer_unit_conversion;
33
use qtype_formulas\local\answer_parser;
34
use qtype_formulas\local\evaluator;
35
use qtype_formulas\local\lexer;
36
use qtype_formulas\local\random_parser;
37
use qtype_formulas\local\parser;
38
use qtype_formulas\local\token;
39
use qtype_formulas\unit_conversion_rules;
40

41
defined('MOODLE_INTERNAL') || die();
42

UNCOV
43
require_once($CFG->dirroot . '/question/type/formulas/questiontype.php');
×
UNCOV
44
require_once($CFG->dirroot . '/question/type/formulas/answer_unit.php');
×
UNCOV
45
require_once($CFG->dirroot . '/question/type/formulas/conversion_rules.php');
×
UNCOV
46
require_once($CFG->dirroot . '/question/behaviour/adaptivemultipart/behaviour.php');
×
47

48
/**
49
 * Base class for the Formulas question type.
50
 *
51
 * @copyright 2010-2011 Hon Wai, Lau; 2023 Philipp Imhof
52
 * @author Hon Wai, Lau <lau65536@gmail.com>
53
 * @author Philipp Imhof
54
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
55
 */
56
class qtype_formulas_question extends question_graded_automatically_with_countback
57
        implements question_automatically_gradable_with_multiple_parts {
58

59
    /** @var int seed used to initialize the RNG; needed to restore an attempt state */
60
    public int $seed;
61

62
    /** @var ?evaluator evaluator class, this is where the evaluation stuff happens */
63
    public ?evaluator $evaluator = null;
64

65
    /** @var string $varsrandom definition text for random variables, as entered in the edit form */
66
    public string $varsrandom;
67

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

71
    /** @var qtype_formulas_part[] parts of the question */
72
    public $parts = [];
73

74
    /** @var string numbering (if any) of answers */
75
    public string $answernumbering;
76

77
    /** @var int number of parts in this question, used e.g. by the renderer */
78
    public int $numparts;
79

80
    /**
81
     * @var string[] strings (one more than $numparts) containing fragments from the question's main text
82
     *               that surround the parts' subtexts; used by the renderer
83
     */
84
    public array $textfragments;
85

86
    /** @var string $correctfeedback combined feedback for correct answer */
87
    public string $correctfeedback;
88

89
    /** @var int $correctfeedbackformat format of combined feedback for correct answer */
90
    public int $correctfeedbackformat;
91

92
    /** @var string $partiallycorrectfeedback combined feedback for partially correct answer */
93
    public string $partiallycorrectfeedback;
94

95
    /** @var int $partiallycorrectfeedbackformat format of combined feedback for partially correct answer */
96
    public int $partiallycorrectfeedbackformat;
97

98
    /** @var string $incorrectfeedback combined feedback for in correct answer */
99
    public string $incorrectfeedback;
100

101
    /** @var int $incorrectfeedbackformat format of combined feedback for incorrect answer */
102
    public int $incorrectfeedbackformat;
103

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

118
        // Otherwise, pass it on to the parent class.
119
        return parent::make_behaviour($qa, $preferredbehaviour);
612✔
120
    }
121

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

137
        // Create an empty evaluator, feed it with the random variables and instantiate
138
        // them.
139
        $this->evaluator = new evaluator();
1,224✔
140
        $randomparser = new random_parser($this->varsrandom);
1,224✔
141
        $this->evaluator->evaluate($randomparser->get_statements());
1,224✔
142
        $this->evaluator->instantiate_random_variables($this->seed);
1,224✔
143

144
        // Parse the definition of global variables and evaluate them, taking into account
145
        // the random variables.
146
        $globalparser = new parser($this->varsglobal, $randomparser->export_known_variables());
1,224✔
147
        $this->evaluator->evaluate($globalparser->get_statements());
1,224✔
148

149
        // Set the question's $numparts property.
150
        $this->numparts = count($this->parts);
1,224✔
151

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

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

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

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

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

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

201
        parent::apply_attempt_state($step);
34✔
202
    }
203

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

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

233
    /**
234
     * Return the number of variants that exist for this question. This depends on the definition of
235
     * random variables, so we have to pass through the question's evaluator class. If there is no
236
     * evaluator, we return PHP_INT_MAX.
237
     *
238
     * @return int number of variants or PHP_INT_MAX
239
     */
240
    public function get_num_variants(): int {
241
        // If the question data has not been analyzed yet, we let Moodle
242
        // define the seed freely.
243
        if ($this->evaluator === null) {
34✔
244
            return PHP_INT_MAX;
17✔
245
        }
246
        return $this->evaluator->get_number_of_variants();
34✔
247
    }
248

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

263
        // Call the corresponding function for each part and apply the union operator. Note that
264
        // the first argument takes precedence if a key exists in both arrays, so this will
265
        // replace all answers from $response that have been set in clear_from_response_if_wrong() and
266
        // keep all the others.
NEW
267
        foreach ($this->parts as $part) {
×
NEW
268
            $response = $part->clear_from_response_if_wrong($response) + $response;
×
269
        }
270

NEW
271
        return $response;
×
272
    }
273

274
    /**
275
     * Return the number of parts that have been correctly answered. The renderer will call this function
276
     * when the question is attempted in interactive mode with multiple tries *and* it is setup to show
277
     * the number of correct responses.
278
     *
279
     * @param array $response student's response
280
     * @return array array with [0] = number of correct parts and [1] = total number of parts
281
     */
282
    public function get_num_parts_right(array $response): array {
283
        // Normalize all student answers.
284
        $response = $this->normalize_response($response);
238✔
285

286
        $numcorrect = 0;
238✔
287
        foreach ($this->parts as $part) {
238✔
288
            list('answer' => $answercorrect, 'unit' => $unitcorrect) = $part->grade($response);
238✔
289

290
            if ($answercorrect >= 0.999 && $unitcorrect == true) {
238✔
291
                $numcorrect++;
136✔
292
            }
293
        }
294
        return [$numcorrect, $this->numparts];
238✔
295
    }
296

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

313
    /**
314
     * Return the model answers as entered by the teacher. These answers should normally be sufficient
315
     * to get the maximum grade.
316
     *
317
     * @param qtype_formulas_part|null model answer for every answer / unit box of each part
318
     * @return array model answer for every answer / unit box of each part
319
     */
320
    public function get_correct_response(?qtype_formulas_part $part = null): array {
321
        // If the caller has requested one specific part, just return that response.
322
        if (isset($part)) {
884✔
323
            return $part->get_correct_response();
51✔
324
        }
325

326
        // Otherwise, fetch them all.
327
        $responses = [];
884✔
328
        foreach ($this->parts as $part) {
884✔
329
            $responses += $part->get_correct_response();
884✔
330
        }
331
        return $responses;
884✔
332
    }
333

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

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

376
        // Files from the part's question text should be shown if the part ID matches one of our parts.
377
        if ($component === 'qtype_formulas' && $filearea === 'answersubqtext') {
85✔
378
            foreach ($this->parts as $part) {
17✔
379
                if ($part->id == $itemid) {
17✔
380
                    return true;
17✔
381
                }
382
            }
383
            // If we did not find a matching part, we don't serve the file.
384
            return false;
17✔
385
        }
386

387
        // If the question is not finished, we don't serve files belong to any feedback field.
388
        $ownfeedbackareas = ['answerfeedback', 'partcorrectfb', 'partpartiallycorrectfb', 'partincorrectfb'];
85✔
389
        if ($component === 'qtype_formulas' && in_array($filearea, $ownfeedbackareas)) {
85✔
390
            // If the $itemid does not belong to our parts, we can leave.
391
            $validpart = false;
51✔
392
            foreach ($this->parts as $part) {
51✔
393
                if ($part->id == $itemid) {
51✔
394
                    $validpart = true;
51✔
395
                    break;
51✔
396
                }
397
            }
398
            if (!$validpart) {
51✔
399
                return false;
51✔
400
            }
401

402
            // If the question is not finished, check if we have a gradable response. If we do,
403
            // calculate the grade and proceed. Otherwise, do not grant access to feedback files.
404
            $state = $qa->get_state();
51✔
405
            if (!$state->is_finished()) {
51✔
406
                $response = $qa->get_last_qt_data();
51✔
407
                if (!$this->is_gradable_response($response)) {
51✔
408
                    return false;
51✔
409
                }
410
                // Response is gradable, so try to grade and get the corresponding state.
411
                list($ignored, $state) = $this->grade_response($response);
17✔
412
            }
413

414
            // Files from the answerfeedback area belong to the part's general feedback. It is showed
415
            // for all answers, if feedback is enabled in the display options.
416
            if ($filearea === 'answerfeedback') {
51✔
417
                return $options->generalfeedback;
17✔
418
            }
419

420
            // Fetching the feedback class, i. e. 'correct' or 'partiallycorrect' or 'incorrect'.
421
            $feedbackclass = $state->get_feedback_class();
34✔
422

423
            // Only show files from specific feedback area if the given answer matches the kind of
424
            // feedback and if specific feedback is enabled in the display options.
425
            return ($options->feedback && $filearea === "part{$feedbackclass}fb");
34✔
426
        }
427

428
        $combinedfeedbackareas = ['correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'];
51✔
429
        if ($component === 'question' && in_array($filearea, $combinedfeedbackareas)) {
51✔
430
            return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args);
17✔
431
        }
432

433
        if ($component === 'question' && $filearea === 'hint') {
34✔
434
            return $this->check_hint_file_access($qa, $options, $args);
17✔
435
        }
436

437
        return parent::check_file_access($qa, $options, $component, $filearea, $args, $forcedownload);
17✔
438
    }
439

440
    /**
441
     * Used by many of the behaviours to determine whether the student has provided enough of an answer
442
     * for the question to be graded automatically, or whether it must be considered aborted.
443
     *
444
     * @param array $response responses, as returned by {@link question_attempt_step::get_qt_data()}
445
     * @return bool whether this response can be graded
446
     */
447
    public function is_gradable_response(array $response): bool {
448
        // Iterate over all parts. If one is not gradable, we return early.
449
        foreach ($this->parts as $part) {
323✔
450
            if (!$part->is_gradable_response($response)) {
323✔
451
                return false;
272✔
452
            }
453
        }
454

455
        // Still here? Then the question is gradable.
456
        return true;
272✔
457
    }
458

459
    /**
460
     * Used by many of the behaviours, to work out whether the student's response to the question is
461
     * complete. That is, whether the question attempt should move to the COMPLETE or INCOMPLETE state.
462
     *
463
     * @param array $response responses, as returned by {@link question_attempt_step::get_qt_data()}
464
     * @return bool whether this response is a complete answer to this question
465
     */
466
    public function is_complete_response(array $response): bool {
467
        // Iterate over all parts. If one part is not complete, we can return early.
468
        foreach ($this->parts as $part) {
1,071✔
469
            if (!$part->is_complete_response($response)) {
1,071✔
470
                return false;
340✔
471
            }
472
        }
473

474
        // Still here? Then all parts have been fully answered.
475
        return true;
799✔
476
    }
477

478
    /**
479
     * Used by many of the behaviours to determine whether the student's response has changed. This
480
     * is normally used to determine that a new set of responses can safely be discarded.
481
     *
482
     * @param array $prevresponse previously recorded responses, as returned by {@link question_attempt_step::get_qt_data()}
483
     * @param array $newresponse new responses, in the same format
484
     * @return bool whether the two sets of responses are the same
485
     */
486
    public function is_same_response(array $prevresponse, array $newresponse) {
487
        // Check each part. If there is a difference in one part, we leave early.
488
        foreach ($this->parts as $part) {
204✔
489
            if (!$part->is_same_response($prevresponse, $newresponse)) {
204✔
490
                return false;
204✔
491
            }
492
        }
493

494
        // Still here? Then it's the same response.
495
        return true;
68✔
496
    }
497

498
    /**
499
     * Produce a plain text summary of a response to be used e. g. in reports.
500
     *
501
     * @param array $response student's response, as might be passed to {@link grade_response()}
502
     * @return string plain text summary
503
     */
504
    public function summarise_response(array $response) {
505
        $summary = [];
850✔
506

507
        // Summarise each part's answers.
508
        foreach ($this->parts as $part) {
850✔
509
            $summary[] = $part->summarise_response($response);
850✔
510
        }
511
        return implode(', ', $summary);
850✔
512
    }
513

514
    /**
515
     * Categorise the student's response according to the categories defined by get_possible_responses.
516
     *
517
     * @param array $response response, as might be passed to {@link grade_response()}
518
     * @return array subpartid => {@link question_classified_response} objects;  empty array if no analysis is possible
519
     */
520
    public function classify_response(array $response) {
521
        // First, we normalize the student's answers.
522
        $response = $this->normalize_response($response);
391✔
523

524
        $classification = [];
391✔
525
        // Now, we do the classification for every part.
526
        foreach ($this->parts as $part) {
391✔
527
            // Unanswered parts can immediately be classified.
528
            if ($part->is_unanswered($response)) {
391✔
529
                $classification[$part->partindex] = question_classified_response::no_response();
102✔
530
                continue;
102✔
531
            }
532

533
            // If there is an answer, we check its correctness.
534
            list('answer' => $answergrade, 'unit' => $unitcorrect) = $part->grade($response);
323✔
535

536
            if ($part->postunit !== '') {
323✔
537
                // The unit can only be correct (1.0) or wrong (0.0).
538
                // The answer can be any float from 0.0 to 1.0 inclusive.
539
                if ($answergrade >= 0.999 && $unitcorrect) {
170✔
540
                    $classification[$part->partindex] = new question_classified_response(
34✔
541
                            'right', $part->summarise_response($response), 1);
34✔
542
                } else if ($unitcorrect) {
136✔
543
                    $classification[$part->partindex] = new question_classified_response(
34✔
544
                            'wrongvalue', $part->summarise_response($response), 0);
34✔
545
                } else if ($answergrade >= 0.999) {
102✔
546
                    $classification[$part->partindex] = new question_classified_response(
34✔
547
                            'wrongunit', $part->summarise_response($response), 1 - $part->unitpenalty);
34✔
548
                } else {
549
                    $classification[$part->partindex] = new question_classified_response(
122✔
550
                            'wrong', $part->summarise_response($response), 0);
122✔
551
                }
552
            } else {
553
                if ($answergrade >= .999) {
153✔
554
                    $classification[$part->partindex] = new question_classified_response(
102✔
555
                            'right', $part->summarise_response($response), $answergrade);
102✔
556
                } else {
557
                     $classification[$part->partindex] = new question_classified_response(
102✔
558
                            'wrong', $part->summarise_response($response), $answergrade);
102✔
559
                }
560
            }
561
        }
562
        return $classification;
391✔
563
    }
564

565
    /**
566
     * This method is called by the renderer when the question is in "invalid" state, i. e. if it
567
     * does not have a complete response (for immediate feedback or interactive mode) or if it has
568
     * an invalid part (in adaptive multipart mode).
569
     *
570
     * @param array $response student's response
571
     * @return string error message
572
     */
573
    public function get_validation_error(array $response): string {
574
        // If is_any_part_invalid() is true, that means no part is gradable, i. e. no fields
575
        // have been filled.
576
        if ($this->is_any_part_invalid($response)) {
51✔
577
            return get_string('allfieldsempty', 'qtype_formulas');
51✔
578
        }
579

580
        // If at least one part is gradable and yet the question is in "invalid" state, that means
581
        // that the behaviour expected all fields to be filled.
NEW
582
        return get_string('pleaseputananswer', 'qtype_formulas');
×
583
    }
584

585
    /**
586
     * Grade a response to the question, returning a fraction between get_min_fraction()
587
     * and 1.0, and the corresponding {@link question_state} right, partial or wrong. This
588
     * method is used with immediate feedback, with adaptive mode and with interactive mode. It
589
     * is called after the studenet clicks "submit and finish" when deferred feedback is active.
590
     *
591
     * @param array $response responses, as returned by {@link question_attempt_step::get_qt_data()}
592
     * @return array [0] => fraction (grade) and [1] => corresponding question state
593
     */
594
    public function grade_response(array $response) {
595
        $response = $this->normalize_response($response);
663✔
596

597
        $totalpossible = 0;
663✔
598
        $achievedmarks = 0;
663✔
599
        // Separately grade each part.
600
        foreach ($this->parts as $part) {
663✔
601
            // Count the total number of points for this part.
602
            $totalpossible += $part->answermark;
663✔
603

604
            $partsgrade = $part->grade($response);
663✔
605
            $fraction = $partsgrade['answer'];
663✔
606
            // If unit is wrong, make the necessary deduction.
607
            if ($partsgrade['unit'] === false) {
663✔
608
                $fraction = $fraction * (1 - $part->unitpenalty);
187✔
609
            }
610

611
            // Add the number of points achieved to the total.
612
            $achievedmarks += $part->answermark * $fraction;
663✔
613
        }
614

615
        // Finally, calculate the overall fraction of points received vs. possible points
616
        // and return the fraction together with the correct question state (i. e. correct,
617
        // partiall correct or wrong).
618
        $fraction = $achievedmarks / $totalpossible;
663✔
619
        return [$fraction, question_state::graded_state_for_fraction($fraction)];
663✔
620
    }
621

622
    /**
623
     * This method is called in multipart adaptive mode to grade the of the question
624
     * that can be graded. It returns the grade and penalty for each part, if (and only if)
625
     * the answer to that part has been changed since the last try. For parts that were
626
     * not retried, no grade or penalty should be returned.
627
     *
628
     * @param array $response current response (all fields)
629
     * @param array $lastgradedresponses array containing the (full) response given when each part registered
630
     *      an attempt for the last time; if there has been no try for a certain part, the corresponding key
631
     *      will be missing. Note that this is not the "history record" of all tries.
632
     * @param bool $finalsubmit true when the student clicks "submit all and finish"
633
     * @return array part name => qbehaviour_adaptivemultipart_part_result
634
     */
635
    public function grade_parts_that_can_be_graded(array $response, array $lastgradedresponses, $finalsubmit) {
636
        $partresults = [];
340✔
637

638
        foreach ($this->parts as $part) {
340✔
639
            // Check whether we already have an attempt for this part. If we don't, we create an
640
            // empty response.
641
            $lastresponse = [];
340✔
642
            if (array_key_exists($part->partindex, $lastgradedresponses)) {
340✔
643
                $lastresponse = $lastgradedresponses[$part->partindex];
238✔
644
            }
645

646
            // Check whether the response has been changed since the last attempt. If it has not,
647
            // we are done for this part.
648
            if ($part->is_same_response($lastresponse, $response)) {
340✔
649
                continue;
153✔
650
            }
651

652
            $partsgrade = $part->grade($response);
323✔
653
            $fraction = $partsgrade['answer'];
323✔
654
            // If unit is wrong, make the necessary deduction.
655
            if ($partsgrade['unit'] === false) {
323✔
656
                $fraction = $fraction * (1 - $part->unitpenalty);
51✔
657
            }
658

659
            $partresults[$part->partindex] = new qbehaviour_adaptivemultipart_part_result(
323✔
660
                $part->partindex, $fraction, $this->penalty
323✔
661
            );
323✔
662
        }
663

664
        return $partresults;
340✔
665
    }
666

667
    /**
668
     * Get a list of all the parts of the question and the weight they have within
669
     * the question.
670
     *
671
     * @return array part identifier => weight
672
     */
673
    public function get_parts_and_weights() {
674
        // First, we calculate the sum of all marks.
675
        $sum = 0;
255✔
676
        foreach ($this->parts as $part) {
255✔
677
            $sum += $part->answermark;
255✔
678
        }
679

680
        // Now that the total is known, we calculate each part's weight.
681
        $weights = [];
255✔
682
        foreach ($this->parts as $part) {
255✔
683
            $weights[$part->partindex] = $part->answermark / $sum;
255✔
684
        }
685

686
        return $weights;
255✔
687
    }
688

689
    /**
690
     * Check whether two responses for a given part (and only for that part) are identical.
691
     * This is used when working with multiple tries in order to avoid getting a penalty
692
     * deduction for an unchanged wrong answer that has already been counted before.
693
     *
694
     * @param string $id part indentifier
695
     * @param array $prevresponse previously recorded responses (for entire question)
696
     * @param array $newresponse new responses (for entire question)
697
     * @return bool
698
     */
699
    public function is_same_response_for_part($id, array $prevresponse, array $newresponse): bool {
700
        return $this->parts[$id]->is_same_response($prevresponse, $newresponse);
51✔
701
    }
702

703
    /**
704
     * This is called by adaptive multiplart behaviour in order to determine whether the question
705
     * state should be moved to question_state::$invalid; many behaviours mainly or exclusively
706
     * use !is_complete_response() for that. We will return true if *no* part is gradable,
707
     * because in that case it does not make sense to proceed. If at least one part has been
708
     * answered (at least partially), we say that no part is invalid, because that allows the student
709
     * to get feedback for the answered parts.
710
     *
711
     * @param array $response student's response
712
     * @return bool returning false
713
     */
714
    public function is_any_part_invalid(array $response): bool {
715
        // Iterate over all parts. If at least one part is gradable, we can leave early.
716
        foreach ($this->parts as $part) {
204✔
717
            if ($part->is_gradable_response($response)) {
204✔
718
                return false;
204✔
719
            }
720
        }
721

722
        return true;
51✔
723
    }
724

725
    /**
726
     * Work out a final grade for this attempt, taking into account all the tries the student made.
727
     * This method is called in interactive mode when all tries are done or when the user hits
728
     * 'Submit and finish'.
729
     *
730
     * @param array $responses response for each try, each element (1 <= n <= $totaltries) is a response array
731
     * @param int $totaltries maximum number of tries allowed
732
     * @return float grade that should be awarded for this sequence of responses
733
     */
734
    public function compute_final_grade($responses, $totaltries): float {
735
        $obtainedgrade = 0;
119✔
736
        $maxgrade = 0;
119✔
737

738
        foreach ($this->parts as $part) {
119✔
739
            $maxgrade += $part->answermark;
119✔
740

741
            // We start with an empty last response.
742
            $lastresponse = [];
119✔
743
            $lastchange = 0;
119✔
744

745
            $partfraction = 0;
119✔
746

747
            foreach ($responses as $responseindex => $response) {
119✔
748
                // If the response has not changed, we have nothing to do.
749
                if ($part->is_same_response($lastresponse, $response)) {
119✔
750
                    continue;
34✔
751
                }
752

753
                $response = $this->normalize_response($response);
119✔
754

755
                // Otherwise, save this as the last response and store the index where
756
                // the response was changed for the last time.
757
                $lastresponse = $response;
119✔
758
                $lastchange = $responseindex;
119✔
759

760
                // Obtain the grade for the current response.
761
                $partgrade = $part->grade($response);
119✔
762

763
                $partfraction = $partgrade['answer'];
119✔
764
                // If unit is wrong, make the necessary deduction.
765
                if ($partgrade['unit'] === false) {
119✔
766
                    $partfraction = $partfraction * (1 - $part->unitpenalty);
51✔
767
                }
768
            }
769
            $obtainedgrade += $part->answermark * max(0,  $partfraction - $lastchange * $this->penalty);
119✔
770
        }
771

772
        return $obtainedgrade / $maxgrade;
119✔
773
    }
774

775
    /**
776
     * Set up an evaluator class for every part and have it evaluate the local variables.
777
     *
778
     * @return void
779
     */
780
    public function initialize_part_evaluators(): void {
781
        // For every part, we clone the question's evaluator in order to have the
782
        // same set of (instantiated) random and global variables.
783
        foreach ($this->parts as $part) {
1,224✔
784
            $part->evaluator = clone $this->evaluator;
1,224✔
785

786
            // Parse and evaluate the local variables, if there are any. We do not need to
787
            // retrieve or store the result, because the vars will be set inside the evaluator.
788
            if (!empty($part->vars1)) {
1,224✔
NEW
789
                $parser = new parser($part->vars1);
×
NEW
790
                $part->evaluator->evaluate($parser->get_statements());
×
791
            }
792

793
            // Parse, evaluate and store the model answers. They will be returned as tokens,
794
            // so we need to "unpack" them. We always store the model answers as an array; if
795
            // there is only one answer, we wrap the value into an array.
796
            $part->get_evaluated_answers();
1,224✔
797
        }
798
    }
799

800
    /**
801
     * Normalize student response for each part, i. e. split number and unit for combined answer
802
     * fields, trim answers and set missing answers to empty string to make sure all expected
803
     * response fields are set.
804
     *
805
     * @param array $response the student's response
806
     * @return array normalized response
807
     */
808
    public function normalize_response(array $response): array {
809
        $result = [];
884✔
810

811
        // Normalize the responses for each part.
812
        foreach ($this->parts as $part) {
884✔
813
            $result += $part->normalize_response($response);
884✔
814
        }
815

816
        // Set the 'normalized' key in order to mark the response as normalized; this is useful for
817
        // certain other functions, because it changes a combined field e.g. from 0_ to 0_0 and 0_1.
818
        $result['normalized'] = true;
884✔
819

820
        return $result;
884✔
821
    }
822
}
823

824
/**
825
 * Class to represent a question subpart, loaded from the question_answers table
826
 * in the database.
827
 *
828
 * @copyright  2012 Jean-Michel Védrine, 2023 Philipp Imhof
829
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
830
 */
831
class qtype_formulas_part {
832

833
    /** @var ?evaluator the part's evaluator class */
834
    public ?evaluator $evaluator = null;
835

836
    /** @var array store the evaluated model answer(s) */
837
    public array $evaluatedanswers = [];
838

839
    /** @var int the part's id */
840
    public int $id;
841

842
    /** @var int the parents question's id */
843
    public int $questionid;
844

845
    /** @var int the part's position among all parts of the question */
846
    public int $partindex;
847

848
    /** @var string the part's placeholder, e.g. #1 */
849
    public string $placeholder;
850

851
    /** @var float the maximum grade for this part */
852
    public float $answermark;
853

854
    /** @var int answer type (number, numerical, numerical formula, algebraic) */
855
    public int $answertype;
856

857
    /** @var int number of answer boxes (not including a possible unit box) for this part */
858
    public int $numbox;
859

860
    /** @var string definition of local variables */
861
    public string $vars1;
862

863
    /** @var string definition of grading variables */
864
    public string $vars2;
865

866
    /** @var string definition of the model answer(s) */
867
    public string $answer;
868

869
    /** @var int whether there are multiple possible answers */
870
    public int $answernotunique;
871

872
    /** @var string definition of the grading criterion */
873
    public string $correctness;
874

875
    /** @var float deduction for a wrong unit */
876
    public float $unitpenalty;
877

878
    /** @var string unit */
879
    public string $postunit;
880

881
    /** @var int the set of basic unit conversion rules to be used */
882
    public int $ruleid;
883

884
    /** @var string additional conversion rules for other accepted base units */
885
    public string $otherrule;
886

887
    /** @var string the part's text */
888
    public string $subqtext;
889

890
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
891
    public int $subqtextformat;
892

893
    /** @var string general feedback for the part */
894
    public string $feedback;
895

896
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
897
    public int $feedbackformat;
898

899
    /** @var string part's feedback for any correct response */
900
    public string $partcorrectfb;
901

902
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
903
    public int $partcorrectfbformat;
904

905
    /** @var string part's feedback for any partially correct response */
906
    public string $partpartiallycorrectfb;
907

908
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
909
    public int $partpartiallycorrectfbformat;
910

911
    /** @var string part's feedback for any incorrect response */
912
    public string $partincorrectfb;
913

914
    /** @var int format constant (FORMAT_MOODLE, FORMAT_HTML, FORMAT_PLAIN or FORMAT_MARKDOWN) */
915
    public int $partincorrectfbformat;
916

917
    /**
918
     * Constructor.
919
     */
920
    public function __construct() {
921
    }
1,598✔
922

923
    /**
924
     * Whether or not a unit field is used in this part.
925
     *
926
     * @return bool
927
     */
928
    public function has_unit(): bool {
929
        return $this->postunit !== '';
1,462✔
930
    }
931

932
    /**
933
     * Whether or not the part has a combined input field for the number and the unit.
934
     *
935
     * @return bool
936
     */
937
    public function has_combined_unit_field(): bool {
938
        // In order to have a combined unit field, we must first assure that:
939
        // - there is a unit
940
        // - there is not more than one answer box
941
        // - the answer is not of the type algebraic formula.
942
        if (!$this->has_unit() || $this->numbox > 1 || $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
1,462✔
943
            return false;
1,003✔
944
        }
945

946
        // Furthermore, there must be either a {_0}{_u} without whitespace in the part's text
947
        // (meaning the user explicitly wants a combined unit field) or no answer box placeholders
948
        // at all, neither for the answer nor for the unit.
949
        $combinedrequested = strpos($this->subqtext, '{_0}{_u}') !== false;
527✔
950
        $noplaceholders = strpos($this->subqtext, '{_0}') === false && strpos($this->subqtext, '{_u}') === false;
527✔
951
        return $combinedrequested || $noplaceholders;
527✔
952
    }
953

954
    /**
955
     * Whether or not the part has a separate input field for the unit.
956
     *
957
     * @return bool
958
     */
959
    public function has_separate_unit_field(): bool {
960
        return $this->has_unit() && !$this->has_combined_unit_field();
1,054✔
961
    }
962

963
    /**
964
     * Check whether the previous response and the new response are the same for this part's fields.
965
     *
966
     * @param array $prevresponse previously recorded responses (for entire question)
967
     * @param array $newresponse new responses (for entire question)
968
     * @return bool
969
     */
970
    public function is_same_response(array $prevresponse, array $newresponse): bool {
971
        // Compare previous response and new response for every expected key.
972
        // If we have a difference at one point, we can return early.
973
        foreach (array_keys($this->get_expected_data()) as $key) {
476✔
974
            if (!question_utils::arrays_same_at_key_missing_is_blank($prevresponse, $newresponse, $key)) {
476✔
975
                return false;
459✔
976
            }
977
        }
978

979
        // Still here? That means they are all the same.
980
        return true;
221✔
981
    }
982

983
    /**
984
     * Return the expected fields and data types for all answer boxes this part. This function
985
     * is called by the main question's {@link get_expected_data()} method.
986
     *
987
     * @return array
988
     */
989
    public function get_expected_data(): array {
990
        // The combined unit field is only possible for parts with one
991
        // single answer box. If there are multiple input boxes, the
992
        // number and unit box will not be merged.
993
        if ($this->has_combined_unit_field()) {
1,037✔
994
            return ["{$this->partindex}_" => PARAM_RAW];
272✔
995
        }
996

997
        // First, we expect the answers, counting from 0 to numbox - 1.
998
        $expected = [];
799✔
999
        for ($i = 0; $i < $this->numbox; $i++) {
799✔
1000
            $expected["{$this->partindex}_$i"] = PARAM_RAW;
799✔
1001
        }
1002

1003
        // If there is a separate unit field, we add it to the list.
1004
        if ($this->has_separate_unit_field()) {
799✔
1005
            $expected["{$this->partindex}_{$this->numbox}"] = PARAM_RAW;
187✔
1006
        }
1007
        return $expected;
799✔
1008
    }
1009

1010
    /**
1011
     * Parse a string (i. e. the part's text) looking for answer box placeholders.
1012
     * Answer box placeholders have one of the following forms:
1013
     * - {_u} for the unit box
1014
     * - {_n} for an answer box, n must be an integer
1015
     * - {_n:str} for radio buttons, str must be a variable name
1016
     * - {_n:str:MCE} for a drop down field, MCE must be verbatim
1017
     * Note: {_0:MCE} is valid and will lead to radio boxes based on the variable MCE.
1018
     * Every answer box in the array will itself be an associative array with the
1019
     * keys 'placeholder' (the entire placeholder), 'options' (the name of the variable containing
1020
     * the options for the radio list or the dropdown) and 'dropdown' (true or false).
1021
     * The method is declared static in order to allow its usage during form validation when
1022
     * there is no actual question object.
1023
     * TODO: allow {_n|50px} or {_n|10} to control size of the input field
1024
     *
1025
     * @param string $text string to be parsed
1026
     * @return array
1027
     */
1028
    public static function scan_for_answer_boxes(string $text): array {
1029
        // Match the text and store the matches.
1030
        preg_match_all('/\{(_u|_\d+)(:(_[A-Za-z]|[A-Za-z]\w*)(:(MCE))?)?\}/', $text, $matches);
816✔
1031

1032
        $boxes = [];
816✔
1033

1034
        // The array $matches[1] contains the matches of the first capturing group, i. e. _1 or _u.
1035
        foreach ($matches[1] as $i => $match) {
816✔
1036
            // Duplicates are not allowed.
1037
            if (array_key_exists($match, $boxes)) {
510✔
1038
                throw new Exception(get_string('error_answerbox_duplicate', 'qtype_formulas', $match));
102✔
1039
            }
1040
            // The array $matches[0] contains the entire pattern, e.g. {_1:var:MCE} or simply {_3}. This
1041
            // text is later needed to replace the placeholder by the input element.
1042
            // With $matches[3], we can access the name of the variable containing the options for the radio
1043
            // boxes or the drop down list.
1044
            // Finally, the array $matches[4] will contain ':MCE' in case this has been specified. Otherwise,
1045
            // there will be an empty string.
1046
            // TODO: add option 'size' (for characters) or 'width' (for pixel width).
1047
            $boxes[$match] = [
510✔
1048
                'placeholder' => $matches[0][$i],
510✔
1049
                'options' => $matches[3][$i],
510✔
1050
                'dropdown' => ($matches[4][$i] === ':MCE'),
510✔
1051
            ];
510✔
1052
        }
1053
        return $boxes;
714✔
1054
    }
1055

1056
    /**
1057
     * Produce a plain text summary of a response for the part.
1058
     *
1059
     * @param array $response a response, as might be passed to {@link grade_response()}.
1060
     * @return string a plain text summary of that response, that could be used in reports.
1061
     */
1062
    public function summarise_response(array $response) {
1063
        $summary = [];
850✔
1064

1065
        $isnormalized = array_key_exists('normalized', $response);
850✔
1066

1067
        // If the part has a combined unit field, we want to have the number and the unit
1068
        // to appear together in the summary.
1069
        if ($this->has_combined_unit_field()) {
850✔
1070
            // If the answer is normalized, the combined field has already been split, so we
1071
            // recombine both parts.
1072
            if ($isnormalized) {
323✔
1073
                return trim($response["{$this->partindex}_0"] . " " . $response["{$this->partindex}_1"]);
68✔
1074
            }
1075

1076
            // Otherwise, we check whether the key 0_ or similar is present in the response. If it is,
1077
            // we return that value.
1078
            if (isset($response["{$this->partindex}_"])) {
323✔
1079
                return $response["{$this->partindex}_"];
323✔
1080
            }
1081
        }
1082

1083
        // Iterate over all expected answer fields and if there is a corresponding
1084
        // answer in $response, add its value to the summary array.
1085
        foreach (array_keys($this->get_expected_data()) as $key) {
561✔
1086
            if (array_key_exists($key, $response)) {
561✔
1087
                $summary[] = $response[$key];
544✔
1088
            }
1089
        }
1090

1091
        // Transform the array to a comma-separated list for a nice summary.
1092
        return implode(', ', $summary);
561✔
1093
    }
1094

1095
    /**
1096
     * Normalize student response for current part, i. e. split number and unit for combined answer
1097
     * fields, trim answers and set missing answers to empty string to make sure all expected
1098
     * response fields are set.
1099
     *
1100
     * @param array $response student's full response
1101
     * @return array normalized response for this part only
1102
     */
1103
    public function normalize_response(array $response): array {
1104
        $result = [];
1,003✔
1105

1106
        // There might be a combined field for number and unit which would be called i_.
1107
        // We check this first. A combined field is only possible, if there is not more
1108
        // than one answer, so we can safely use i_0 for the number and i_1 for the unit.
1109
        $name = "{$this->partindex}_";
1,003✔
1110
        if (isset($response[$name])) {
1,003✔
1111
            $combined = trim($response[$name]);
340✔
1112

1113
            // We try to parse the student's response in order to find the position where
1114
            // the unit presumably starts. If parsing fails (e. g. because there is a syntax
1115
            // error), we consider the entire response to be the "number". It will later be graded
1116
            // wrong anyway.
1117
            try {
1118
                $parser = new answer_parser($combined);
340✔
1119
                $splitindex = $parser->find_start_of_units();
340✔
NEW
1120
            } catch (Throwable $t) {
×
1121
                // TODO: convert to non-capturing catch.
NEW
1122
                $splitindex = PHP_INT_MAX;
×
1123
            }
1124

1125
            $number = trim(substr($combined, 0, $splitindex));
340✔
1126
            $unit = trim(substr($combined, $splitindex));
340✔
1127

1128
            $result["{$name}0"] = $number;
340✔
1129
            $result["{$name}1"] = $unit;
340✔
1130
            return $result;
340✔
1131
        }
1132

1133
        // Otherwise, we iterate from 0 to numbox, inclusive (if there is a unit field) or exclusive.
1134
        $count = $this->numbox;
986✔
1135
        if ($this->has_unit()) {
986✔
1136
            $count++;
459✔
1137
        }
1138
        for ($i = 0; $i < $count; $i++) {
986✔
1139
            $name = "{$this->partindex}_$i";
986✔
1140

1141
            // If there is an answer, we strip white space from the start and end.
1142
            // Missing answers should be empty strings.
1143
            if (isset($response[$name])) {
986✔
1144
                $result[$name] = trim($response[$name]);
986✔
1145
            } else {
1146
                $result[$name] = '';
323✔
1147
            }
1148

1149
            // Restrict the answer's length to 128 characters. There is no real need
1150
            // for this, but it was done in the first versions, so we'll keep it for
1151
            // backwards compatibility.
1152
            if (strlen($result[$name]) > 128) {
986✔
1153
                $result[$name] = substr($result[$name], 0, 128);
17✔
1154
            }
1155
        }
1156

1157
        return $result;
986✔
1158
    }
1159

1160
    /**
1161
     * Determines whether the student has entered enough in order for this part to
1162
     * be graded. We consider a part gradable, if it is not unanswered, i. e. if at
1163
     * least some field has been filled.
1164
     *
1165
     * @param array $response
1166
     * @return bool
1167
     */
1168
    public function is_gradable_response(array $response): bool {
1169
        return !$this->is_unanswered($response);
323✔
1170
    }
1171

1172
    /**
1173
     * Determines whether the student has provided a complete answer to this part,
1174
     * i. e. if all fields have been filled. This method can be called before the
1175
     * response is normalized, so we cannot be sure all array keys exist as we would
1176
     * expect them.
1177
     *
1178
     * @param array $response
1179
     * @return bool
1180
     */
1181
    public function is_complete_response(array $response): bool {
1182
        // First, we check if there is a combined unit field. In that case, there will
1183
        // be only one field to verify.
1184
        if ($this->has_combined_unit_field()) {
1,071✔
1185
            return !empty($response["{$this->partindex}_"]) && strlen($response["{$this->partindex}_"]) > 0;
306✔
1186
        }
1187

1188
        // If we are still here, we do now check all "normal" fields. If one is empty,
1189
        // we can return early.
1190
        for ($i = 0; $i < $this->numbox; $i++) {
765✔
1191
            if (!isset($response["{$this->partindex}_{$i}"]) || strlen($response["{$this->partindex}_{$i}"]) == 0) {
765✔
1192
                return false;
272✔
1193
            }
1194
        }
1195

1196
        // Finally, we check whether there is a separate unit field and, if necessary,
1197
        // make sure it is not empty.
1198
        if ($this->has_separate_unit_field()) {
629✔
1199
            return !empty($response["{$this->partindex}_{$this->numbox}"])
102✔
1200
                && strlen($response["{$this->partindex}_{$this->numbox}"]) > 0;
102✔
1201
        }
1202

1203
        // Still here? That means no expected field was missing and no fields were empty.
1204
        return true;
527✔
1205
    }
1206

1207
    /**
1208
     * Determines whether the part (as a whole) is unanswered.
1209
     *
1210
     * @param array $response
1211
     * @return bool
1212
     */
1213
    public function is_unanswered(array $response): bool {
1214
        $isnormalized = array_key_exists('normalized', $response);
714✔
1215

1216
        // If there is a combined number/unit answer, we know that there are no other
1217
        // answers, so we just check this one. However, if the response has already been
1218
        // "normalized", we cannot use this shortcut, because then the combined unit field's
1219
        // content has already been split into _0 (the number) and _1 (the unit).
1220
        if ($isnormalized === false && $this->has_combined_unit_field()) {
714✔
1221
            // If the key does not exist, there is a problem and we consider the part as
1222
            // unanswered.
1223
            if (!array_key_exists("{$this->partindex}_", $response)) {
136✔
1224
                return true;
85✔
1225
            }
1226
            // If the answer is empty, but not equivalent to zero, we consider the part as
1227
            // unanswered.
1228
            $tocheck = $response["{$this->partindex}_"];
136✔
1229
            return empty($tocheck) && !is_numeric($tocheck);
136✔
1230
        }
1231

1232
        // Otherwise, we check all answer boxes (including a possible unit) of this part.
1233
        // If at least one is not empty, the part has been answered. If there is a unit field,
1234
        // we will check this in the same way, even if it should not actually be numeric. We don't
1235
        // need to care about that, because a wrong answer is still an answer.
1236
        // Note that $response will contain *all* answers for *all* parts.
1237
        $count = $this->numbox;
578✔
1238
        if ($this->has_unit()) {
578✔
1239
            $count++;
221✔
1240
        }
1241
        for ($i = 0; $i < $count; $i++) {
578✔
1242
            // If the key does not exist, there is a problem and we consider the part as
1243
            // unanswered.
1244
            if (!array_key_exists("{$this->partindex}_{$i}", $response)) {
578✔
1245
                return true;
187✔
1246
            }
1247
            // If the answer field is not empty or it is equivalent to zero, we consider
1248
            // the part as answered and leave early.
1249
            $tocheck = $response["{$this->partindex}_{$i}"];
527✔
1250
            if (!empty($tocheck) || is_numeric($tocheck)) {
527✔
1251
                return false;
459✔
1252
            }
1253
        }
1254

1255
        // Still here? Then no fields were filled.
1256
        return true;
102✔
1257
    }
1258

1259
    /**
1260
     * Return the part's evaluated answers. The teacher has probably entered them using the
1261
     * various (random, global and/or local) variables. This function calculates the numerical
1262
     * value of all answers. If the answer type is 'algebraic formula', the answers are not
1263
     * evaluated (this would not make sense), but the non-algebraic variables will be replaced
1264
     * by their respective values, e.g. "a*x^2" could become "2*x^2" if the variable a is defined
1265
     * to be 2.
1266
     *
1267
     * @return array
1268
     */
1269
    public function get_evaluated_answers(): array {
1270
        // If we already know the evaluated answers for this part, we can simply return them.
1271
        if (!empty($this->evaluatedanswers)) {
1,224✔
1272
            return $this->evaluatedanswers;
1,105✔
1273
        }
1274

1275
        // Still here? Then let's evaluate the answers.
1276
        $result = [];
1,224✔
1277

1278
        // Check whether the part uses the algebraic answer type.
1279
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
1,224✔
1280

1281
        $parser = new parser($this->answer, $this->evaluator->export_variable_list());
1,224✔
1282
        $result = $this->evaluator->evaluate($parser->get_statements())[0];
1,224✔
1283

1284
        // The $result will now be a token with its value being either a literal (string, number)
1285
        // or an array of literal tokens. If we have one single answer, we wrap it into an array
1286
        // before continuing. Otherwise we convert the array of tokens into an array of literals.
1287
        if ($result->type & token::ANY_LITERAL) {
1,224✔
1288
            $this->evaluatedanswers = [$result->value];
1,190✔
1289
        } else {
1290
            $this->evaluatedanswers = array_map(function ($element) {
34✔
1291
                return $element->value;
34✔
1292
            }, $result->value);
34✔
1293
        }
1294

1295
        // If the answer type is algebraic, substitute all non-algebraic variables by
1296
        // their numerical value.
1297
        if ($isalgebraic) {
1,224✔
1298
            foreach ($this->evaluatedanswers as &$answer) {
17✔
1299
                $answer = $this->evaluator->substitute_variables_in_algebraic_formula($answer);
17✔
1300
            }
1301
            // In case we later write to $answer, this would alter the last entry of the $modelanswers
1302
            // array, so we'd better remove the reference to make sure this won't happend.
1303
            unset($answer);
17✔
1304
        }
1305

1306
        return $this->evaluatedanswers;
1,224✔
1307
    }
1308

1309
    /**
1310
     * This function takes an array of algebraic formulas and wraps them in quotes. This is
1311
     * needed e.g. before we feed them to the parser for further processing.
1312
     *
1313
     * @param array $formulas the formulas to be wrapped in quotes
1314
     * @return array array containing wrapped formulas
1315
     */
1316
    private static function wrap_algebraic_formulas_in_quotes(array $formulas): array {
1317
        foreach ($formulas as &$formula) {
136✔
1318
            // If the formula is aready wrapped in quotes (e. g. after an earlier call to this
1319
            // function), there is nothing to do.
1320
            if (preg_match('/^\"[^\"]*\"$/', $formula)) {
136✔
1321
                continue;
51✔
1322
            }
1323

1324
            $formula = '"' . $formula . '"';
85✔
1325
        }
1326
        // In case we later write to $formula, this would alter the last entry of the $formulas
1327
        // array, so we'd better remove the reference to make sure this won't happen.
1328
        unset($formula);
136✔
1329

1330
        return $formulas;
136✔
1331
    }
1332

1333
    /**
1334
     * Check whether algebraic formulas contain a PREFIX operator.
1335
     *
1336
     * @param array $formulas the formulas to check
1337
     * @return bool
1338
     */
1339
    private static function contains_prefix_operator(array $formulas): bool {
1340
        foreach ($formulas as $formula) {
17✔
1341
            $lexer = new lexer($formula);
17✔
1342

1343
            foreach ($lexer->get_tokens() as $token) {
17✔
1344
                if ($token->type === token::PREFIX) {
17✔
1345
                    return true;
17✔
1346
                }
1347
            }
1348
        }
1349

1350
        return false;
17✔
1351
    }
1352

1353
    /**
1354
     * Add several special variables to the question part's evaluator, namely
1355
     * _a for the model answers (array)
1356
     * _r for the student's response (array)
1357
     * _d for the differences between _a and _r (array)
1358
     * _0, _1 and so on for the student's individual answers
1359
     * _err for the absolute error
1360
     * _relerr for the relative error, if applicable
1361
     *
1362
     * @param array $studentanswers the student's response
1363
     * @param float $conversionfactor unit conversion factor, if needed (1 otherwise)
1364
     * @param bool $formodelanswer whether we are doing this to test model answers, i. e. PREFIX operator is allowed
1365
     * @return void
1366
     */
1367
    public function add_special_variables(array $studentanswers, float $conversionfactor, bool $formodelanswer = false): void {
1368
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
901✔
1369

1370
        // First, we set _a to the array of model answers. We can use the
1371
        // evaluated answers. The function get_evaluated_answers() uses a cache.
1372
        // Answers of type alebraic formula must be wrapped in quotes.
1373
        $modelanswers = $this->get_evaluated_answers();
901✔
1374
        if ($isalgebraic) {
901✔
1375
            $modelanswers = self::wrap_algebraic_formulas_in_quotes($modelanswers);
17✔
1376
        }
1377
        $command = '_a = [' . implode(',', $modelanswers ). '];';
901✔
1378

1379
        // The variable _r will contain the student's answers, scaled according to the unit,
1380
        // but not containing the unit. Also, the variables _0, _1, ... will contain the
1381
        // individual answers.
1382
        if ($isalgebraic) {
901✔
1383
            // Students are not allowed to use the PREFIX operator. If they do, we drop out
1384
            // here. Throwing an exception will make sure the grading function awards zero points.
1385
            // Also, we disallow usage of the PREFIX in an algebraic formula's model answer, because
1386
            // that would lead to bad feedback (showing the student a "correct" answer that they cannot
1387
            // type in). In this case, we use a different error message.
1388
            if (self::contains_prefix_operator($studentanswers)) {
17✔
1389
                if ($formodelanswer) {
17✔
NEW
1390
                    throw new Exception(get_string('error_model_answer_prefix', 'qtype_formulas'));
×
1391
                }
1392
                throw new Exception(get_string('error_prefix', 'qtype_formulas'));
17✔
1393
            }
1394
            $studentanswers = self::wrap_algebraic_formulas_in_quotes($studentanswers);
17✔
1395
        }
1396
        $ssqstudentanswer = 0;
901✔
1397
        foreach ($studentanswers as $i => &$studentanswer) {
901✔
1398
            // We only do the calculation if the answer type is not algebraic. For algebraic
1399
            // answers, we don't do anything, because quotes have already been added.
1400
            if (!$isalgebraic) {
901✔
1401
                $studentanswer = $conversionfactor * $studentanswer;
901✔
1402
                $ssqstudentanswer += $studentanswer ** 2;
901✔
1403
            }
1404
            $command .= "_{$i} = {$studentanswer};";
901✔
1405
        }
1406
        unset($studentanswer);
901✔
1407
        $command .= '_r = [' . implode(',', $studentanswers) . '];';
901✔
1408

1409
        // The variable _d will contain the absolute differences between the model answer
1410
        // and the student's response. Using the parser's diff() function will make sure
1411
        // that algebraic answers are correctly evaluated.
1412
        $command .= '_d = diff(_a, _r);';
901✔
1413

1414
        // Prepare the variable _err which is the root of the sum of squared differences.
1415
        $command .= "_err = sqrt(sum(map('*', _d, _d)));";
901✔
1416

1417
        // Finally, calculate the relative error, unless the question uses an algebraic answer.
1418
        if (!$isalgebraic) {
901✔
1419
            // We calculate the sum of squares of all model answers.
1420
            $ssqmodelanswer = 0;
901✔
1421
            foreach ($this->get_evaluated_answers() as $answer) {
901✔
1422
                $ssqmodelanswer += $answer ** 2;
901✔
1423
            }
1424
            // If the sum of squares is 0 (i.e. all answers are 0), then either the student
1425
            // answers are all 0 as well, in which case we set the relative error to 0. Or
1426
            // they are not, in which case we set the relative error to the greatest possible value.
1427
            // Otherwise, the relative error is simply the absolute error divided by the root
1428
            // of the sum of squares.
1429
            if ($ssqmodelanswer == 0) {
901✔
1430
                $command .= '_relerr = ' . ($ssqstudentanswer == 0 ? 0 : PHP_FLOAT_MAX);
17✔
1431
            } else {
1432
                $command .= "_relerr = _err / sqrt({$ssqmodelanswer})";
884✔
1433
            }
1434
        }
1435

1436
        $parser = new parser($command, $this->evaluator->export_variable_list());
901✔
1437
        $this->evaluator->evaluate($parser->get_statements(), true);
901✔
1438
    }
1439

1440
    /**
1441
     * Grade the part and return its grade.
1442
     *
1443
     * @param array $response current response
1444
     * @param bool $finalsubmit true when the student clicks "submit all and finish"
1445
     * @return array 'answer' => grade (0...1) for this response, 'unit' => whether unit is correct (bool)
1446
     */
1447
    public function grade(array $response, bool $finalsubmit = false): array {
1448
        $isalgebraic = $this->answertype == qtype_formulas::ANSWER_TYPE_ALGEBRAIC;
935✔
1449

1450
        // Normalize the student's response for this part, removing answers from other parts.
1451
        $response = $this->normalize_response($response);
935✔
1452

1453
        // Store the unit as entered by the student and get rid of this part of the
1454
        // array, leaving only the inputs from the number fields.
1455
        $studentsunit = '';
935✔
1456
        if ($this->has_unit()) {
935✔
1457
            $studentsunit = trim($response["{$this->partindex}_{$this->numbox}"]);
442✔
1458
            unset($response["{$this->partindex}_{$this->numbox}"]);
442✔
1459
        }
1460

1461
        // For now, let's assume the unit is correct.
1462
        $unitcorrect = true;
935✔
1463

1464
        // Check whether the student's unit is compatible, i. e. whether it can be converted to
1465
        // the unit set by the teacher. If this is the case, we calculate the conversion factor.
1466
        // Otherwise, we set $unitcorrect to false and let the conversion factor be 1, so the
1467
        // result will not be "scaled".
1468
        $conversionfactor = $this->is_compatible_unit($studentsunit);
935✔
1469
        if ($conversionfactor === false) {
935✔
1470
            $conversionfactor = 1;
357✔
1471
            $unitcorrect = false;
357✔
1472
        }
1473

1474
        // The response array does not contain the unit anymore. If we are dealing with algebraic
1475
        // formulas, we must wrap the answers in quotes before we move on. Also, we reset the conversion
1476
        // factor, because it is not needed for algebraic answers.
1477
        if ($isalgebraic) {
935✔
1478
                $response = self::wrap_algebraic_formulas_in_quotes($response);
17✔
1479
            $conversionfactor = 1;
17✔
1480
        }
1481

1482
        // Now we iterate over all student answers, feed them to the parser and evaluate them in order
1483
        // to build an array containing the evaluated response.
1484
        $evaluatedresponse = [];
935✔
1485
        foreach ($response as $answer) {
935✔
1486
            try {
1487
                // Using the known variables upon initialisation allows the teacher to "block"
1488
                // certain built-in functions for the student by overwriting them, e. g. by
1489
                // defining "sin = 1" in the global variables.
1490
                $parser = new answer_parser($answer, $this->evaluator->export_variable_list());
935✔
1491

1492
                // Check whether the answer is valid for the given answer type. If it is not,
1493
                // we just throw an exception to make use of the catch block. Note that if the
1494
                // student's answer was empty, it will fail in this check.
1495
                if (!$parser->is_acceptable_for_answertype($this->answertype)) {
935✔
1496
                    throw new Exception();
408✔
1497
                }
1498

1499
                // Make sure the stack is empty, as there might be left-overs from a previous
1500
                // failed evaluation, e.g. caused by an invalid answer.
1501
                $this->evaluator->clear_stack();
901✔
1502

1503
                $evaluated = $this->evaluator->evaluate($parser->get_statements())[0];
901✔
1504
                $evaluatedresponse[] = token::unpack($evaluated);
901✔
1505
            } catch (Throwable $t) {
408✔
1506
                // TODO: convert to non-capturing catch
1507
                // If parsing, validity check or evaluation fails, we consider the answer as wrong.
1508
                // The unit might be correct, but that won't matter.
1509
                return ['answer' => 0, 'unit' => $unitcorrect];
408✔
1510
            }
1511
        }
1512

1513
        // Add correctness variables using the evaluated response.
1514
        try {
1515
            $this->add_special_variables($evaluatedresponse, $conversionfactor);
901✔
1516
        } catch (Exception $e) {
17✔
1517
            // TODO: convert to non-capturing catch
1518
            // If the special variables cannot be evaluated, the answer will be considered as
1519
            // wrong. Partial credit may be given for the unit. We do not carry on, because
1520
            // evaluation of the grading criterion (and possibly grading variables) generally
1521
            // depends on these special variables.
1522
            return ['answer' => 0, 'unit' => $unitcorrect];
17✔
1523
        }
1524

1525
        // Fetch and evaluate grading variables.
1526
        $gradingparser = new parser($this->vars2);
901✔
1527
        try {
1528
            $this->evaluator->evaluate($gradingparser->get_statements());
901✔
1529
        } catch (Exception $e) {
17✔
1530
            // TODO: convert to non-capturing catch
1531
            // If grading variables cannot be evaluated, the answer will be considered as
1532
            // wrong. Partial credit may be given for the unit. Thus, we do not need to
1533
            // carry on.
1534
            return ['answer' => 0, 'unit' => $unitcorrect];
17✔
1535
        }
1536

1537
        // Fetch and evaluate the grading criterion. If evaluation is not possible,
1538
        // set grade to 0.
1539
        $correctnessparser = new parser($this->correctness);
901✔
1540
        try {
1541
            $evaluatedgrading = $this->evaluator->evaluate($correctnessparser->get_statements())[0];
901✔
1542
            $evaluatedgrading = $evaluatedgrading->value;
901✔
1543
        } catch (Exception $e) {
17✔
1544
            // TODO: convert to non-capturing catch.
1545
            $evaluatedgrading = 0;
17✔
1546
        }
1547

1548
        // Restrict the grade to the closed interval [0,1].
1549
        $evaluatedgrading = min($evaluatedgrading, 1);
901✔
1550
        $evaluatedgrading = max($evaluatedgrading, 0);
901✔
1551

1552
        return ['answer' => $evaluatedgrading, 'unit' => $unitcorrect];
901✔
1553
    }
1554

1555
    /**
1556
     * Check whether the unit in the student's answer can be converted into the expected unit.
1557
     * TODO: refactor this once the unit system has been rewritten
1558
     *
1559
     * @param string $studentsunit unit provided by the student
1560
     * @return float|bool false if not compatible, conversion factor if compatible
1561
     */
1562
    private function is_compatible_unit(string $studentsunit) {
1563
        $checkunit = new answer_unit_conversion();
935✔
1564
        $conversionrules = new unit_conversion_rules();
935✔
1565
        $entry = $conversionrules->entry($this->ruleid);
935✔
1566
        $checkunit->assign_default_rules($this->ruleid, $entry[1]);
935✔
1567
        $checkunit->assign_additional_rules($this->otherrule);
935✔
1568

1569
        $checked = $checkunit->check_convertibility($studentsunit, $this->postunit);
935✔
1570
        if ($checked->convertible) {
935✔
1571
            return $checked->cfactor;
714✔
1572
        }
1573

1574
        return false;
357✔
1575
    }
1576

1577
    /**
1578
     * Return an array containing the correct answers for this question part. If $forfeedback
1579
     * is set to true, multiple choice answers are translated from their list index to their
1580
     * value (e.g. the text) to provide feedback to the student. Also, quotes are stripped
1581
     * from algebraic formulas. Otherwise, the function returns the values as they are needed
1582
     * to obtain full mark at this question.
1583
     *
1584
     * @param bool $forfeedback whether we request correct answers for student feedback
1585
     * @return array list of correct answers
1586
     */
1587
    public function get_correct_response(bool $forfeedback = false): array {
1588
        // Fetch the evaluated answers.
1589
        $answers = $this->get_evaluated_answers();
884✔
1590

1591
        // If we have a combined unit field, we return both the model answer plus the unit
1592
        // in "i_". Combined fields are only possible for parts with one signle answer.
1593
        if ($this->has_combined_unit_field()) {
884✔
1594
            return ["{$this->partindex}_" => trim($answers[0] . ' ' . $this->postunit)];
323✔
1595
        }
1596

1597
        // Strip quotes around algebraic formulas, if the answers are used for feedback.
1598
        if ($forfeedback && $this->answertype === qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
578✔
NEW
1599
            $answers = str_replace('"', '', $answers);
×
1600
        }
1601

1602
        // Otherwise, we build an array with all answers, according to our naming scheme.
1603
        $res = [];
578✔
1604
        for ($i = 0; $i < $this->numbox; $i++) {
578✔
1605
            $res["{$this->partindex}_{$i}"] = $answers[$i];
578✔
1606
        }
1607

1608
        // Finally, if we have a separate unit field, we add this as well.
1609
        if ($this->has_separate_unit_field()) {
578✔
1610
            $res["{$this->partindex}_{$this->numbox}"] = $this->postunit;
153✔
1611
        }
1612

1613
        if ($forfeedback) {
578✔
1614
            $res = $this->translate_mc_answers_for_feedback($res);
68✔
1615
        }
1616

1617
        return $res;
578✔
1618
    }
1619

1620
    /**
1621
     * When using multichoice options in a question (e.g. radio buttons or a dropdown list),
1622
     * the answer will be stored as a number, e.g. 1 for the *second* option. However, when
1623
     * giving feedback to the student, we want to show the actual *value* of the option and not
1624
     * its number. This function makes sure the stored answer is translated accordingly.
1625
     *
1626
     * @param array $response the response given by the student
1627
     * @return array updated response
1628
     */
1629
    public function translate_mc_answers_for_feedback(array $response): array {
1630
        // First, we fetch all answer boxes.
1631
        $boxes = self::scan_for_answer_boxes($this->subqtext);
68✔
1632

1633
        foreach ($boxes as $key => $box) {
68✔
1634
            // If it is not a multiple choice answer, we have nothing to do.
1635
            if ($box['options'] === '') {
34✔
1636
                continue;
17✔
1637
            }
1638

1639
            // Name of the array containing the choices.
1640
            $source = $box['options'];
17✔
1641

1642
            // Student's choice.
1643
            $userschoice = $response["{$this->partindex}$key"];
17✔
1644

1645
            // Fetch the value.
1646
            $parser = new parser("{$source}[$userschoice]");
17✔
1647
            try {
1648
                $result = $this->evaluator->evaluate($parser->get_statements()[0]);
17✔
1649
                $response["{$this->partindex}$key"] = $result->value;
17✔
NEW
1650
            } catch (Exception $e) {
×
1651
                // TODO: convert to non-capturing catch
1652
                // If there was an error, we leave the value as it is. This should
1653
                // not happen, because upon creation of the question, we check whether
1654
                // the variable containing the choices exists.
NEW
1655
                debugging('Could not translate multiple choice index back to its value. This should not happen. ' .
×
NEW
1656
                    'Please file a bug report.');
×
1657
            }
1658
        }
1659

1660
        return $response;
68✔
1661
    }
1662

1663

1664
    /**
1665
     * If the part is not correctly answered, we will set all answers to the empty string. Otherwise, we
1666
     * just return an empty array. This function will be called by the main question (for every part) and
1667
     * will be used to reset answers from wrong parts.
1668
     *
1669
     * @param array $response student's response
1670
     * @return array either an empty array (if part is correct) or an array with all answers being the empty string
1671
     */
1672
    public function clear_from_response_if_wrong(array $response): array {
1673
        // First, we have the response graded.
NEW
1674
        list('answer' => $answercorrect, 'unit' => $unitcorrect) = $this->grade($response);
×
1675

1676
        // If the part's answer is correct (including the unit, if any), we return an empty array.
1677
        // The caller of this function uses our values to overwrite the ones in the response, so
1678
        // that's fine.
NEW
1679
        if ($answercorrect >= 0.999 && $unitcorrect) {
×
NEW
1680
            return [];
×
1681
        }
1682

NEW
1683
        $result = [];
×
NEW
1684
        foreach (array_keys($this->get_expected_data()) as $key) {
×
NEW
1685
            $result[$key] = '';
×
1686
        }
NEW
1687
        return $result;
×
1688
    }
1689
}
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