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

FormulasQuestion / moodle-qtype_formulas / 25630929345

10 May 2026 02:13PM UTC coverage: 97.443% (-0.06%) from 97.498%
25630929345

Pull #326

github

web-flow
Merge 1b760284c into eb60bc71b
Pull Request #326: Improve feedback in adaptive mode

19 of 19 new or added lines in 1 file covered. (100.0%)

2 existing lines in 1 file now uncovered.

4611 of 4732 relevant lines covered (97.44%)

87.99 hits per line

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

94.93
/renderer.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
use qtype_formulas\local\formulas_part;
18

19
/**
20
 * Formulas question renderer class.
21
 *
22
 * @package    qtype_formulas
23
 * @copyright  2009 The Open University
24
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25
 */
26

27
/**
28
 * Base class for generating the bits of output for formulas questions.
29
 *
30
 * @copyright  2009 The Open University
31
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
32
 */
33
class qtype_formulas_renderer extends qtype_with_combined_feedback_renderer {
34
    /** @var string */
35
    const UNIT_FIELD = 'u';
36

37
    /** @var string */
38
    const COMBINED_FIELD = '';
39

40
    /**
41
     * Generate the display of the formulation part of the question. This is the area that
42
     * contains the question text and the controls for students to input their answers.
43
     * Once the question is answered, it will contain the green tick or the red cross and
44
     * the part's general / combined feedback.
45
     *
46
     * @param question_attempt $qa the question attempt to display.
47
     * @param question_display_options $options controls what should and should not be displayed.
48
     * @return ?string HTML fragment.
49
     */
50
    public function formulation_and_controls(question_attempt $qa, question_display_options $options): ?string {
51
        // First, fetch the instantiated question from the attempt.
52
        /** @var qtype_formulas_question $question */
53
        $question = $qa->get_question();
121✔
54

55
        if (count($question->textfragments) !== $question->numparts + 1) {
121✔
56
            $this->output->notification(get_string('error_question_damaged', 'qtype_formulas'), 'error');
×
57
            return null;
×
58
        }
59

60
        $questiontext = '';
121✔
61
        // First, iterate over all parts, put the corresponding fragment of the main question text at the
62
        // right position, followed by the part's text, input and (if applicable) feedback elements.
63
        foreach ($question->parts as $part) {
121✔
64
            $questiontext .= $question->format_text(
121✔
65
                $question->textfragments[$part->partindex],
121✔
66
                $question->questiontextformat,
121✔
67
                $qa,
121✔
68
                'question',
121✔
69
                'questiontext',
121✔
70
                $question->id,
121✔
71
                false,
121✔
72
            );
121✔
73
            $questiontext .= $this->part_formulation_and_controls($qa, $options, $part);
121✔
74
        }
75
        // All parts are done. We now append the final fragment of the main question text. Note that this fragment
76
        // might be empty.
77
        $questiontext .= $question->format_text(
121✔
78
            end($question->textfragments),
121✔
79
            $question->questiontextformat,
121✔
80
            $qa,
121✔
81
            'question',
121✔
82
            'questiontext',
121✔
83
            $question->id,
121✔
84
            false,
121✔
85
        );
121✔
86

87
        // Pack everything in a <div> and, if the question is in an invalid state, append the appropriate error message
88
        // at the very end.
89
        $result = html_writer::tag('div', $questiontext, ['class' => 'qtext']);
121✔
90
        if ($qa->get_state() == question_state::$invalid) {
121✔
91
            $result .= html_writer::nonempty_tag(
2✔
92
                'div',
2✔
93
                $question->get_validation_error($qa->get_last_qt_data()),
2✔
94
                ['class' => 'validationerror']
2✔
95
            );
2✔
96
        }
97

98
        return $result;
121✔
99
    }
100

101
    /**
102
     * Return HTML that needs to be included in the page's <head> when this
103
     * question is used.
104
     *
105
     * @param question_attempt $qa question attempt that will be displayed on the page
106
     * @return string HTML fragment
107
     */
108
    public function head_code(question_attempt $qa): string {
109
        $this->page->requires->js_call_amd(
×
110
            'qtype_formulas/answervalidation',
×
111
            'init',
×
112
            [get_config('qtype_formulas', 'debouncedelay')]
×
113
        );
×
114
        $this->page->requires->js_call_amd(
×
115
            'qtype_formulas/tooltip',
×
116
            'init',
×
117
        );
×
118

119
        return '';
×
120
    }
121

122
    /**
123
     * Return the part text, controls, grading details and feedbacks.
124
     *
125
     * @param question_attempt $qa question attempt that will be displayed on the page
126
     * @param question_display_options $options controls what should and should not be displayed
127
     * @param formulas_part $part question part
128
     * @return void
129
     */
130
    public function part_formulation_and_controls(
131
        question_attempt $qa,
132
        question_display_options $options,
133
        formulas_part $part
134
    ): string {
135

136
        // The behaviour might change the display options per part, so it is safer to clone them here.
137
        $partoptions = clone $options;
121✔
138
        if ($qa->get_behaviour_name() === 'adaptivemultipart') {
121✔
139
            $qa->get_behaviour()->adjust_display_options_for_part($part->partindex, $partoptions);
5✔
140
        }
141

142
        // Fetch information about the outcome: grade, feedback symbol, CSS class to be used.
143
        $outcomedata = $this->get_part_feedback_class_and_symbol($qa, $partoptions, $part);
121✔
144

145
        // First of all, we take the part's question text and its input fields.
146
        $output = $this->get_part_formulation($qa, $partoptions, $part, $outcomedata);
121✔
147

148
        // If the user has requested the feedback symbol to be placed at a special position, we
149
        // do that now. Otherwise, we just append it after the part's text and input boxes.
150
        if (strpos($output, '{_m}') !== false) {
121✔
151
            $output = str_replace('{_m}', $outcomedata->feedbacksymbol, $output);
×
152
        } else {
153
            $output .= $outcomedata->feedbacksymbol;
121✔
154
        }
155

156
        // The part's feedback consists of the combined feedback (correct, partially correct, incorrect -- depending on the
157
        // outcome) and the general feedback which is given in all cases.
158
        $feedback = $this->part_combined_feedback($qa, $partoptions, $part, $outcomedata->fraction);
121✔
159
        $feedback .= $this->part_general_feedback($qa, $partoptions, $part);
121✔
160

161
        // If requested, the correct answer should be appended to the feedback.
162
        if ($partoptions->rightanswer) {
121✔
163
            $feedback .= $this->part_correct_response($part);
18✔
164
        }
165

166
        // Put all feedback into a <div> with the appropriate CSS class and append it to the output.
167
        $output .= html_writer::nonempty_tag('div', $feedback, ['class' => 'formulaspartoutcome outcome']);
121✔
168

169
        return html_writer::tag('div', $output, ['class' => 'formulaspart']);
121✔
170
    }
171

172
    /**
173
     * Return class and symbol for the part feedback.
174
     *
175
     * @param question_attempt $qa question attempt that will be displayed on the page
176
     * @param question_display_options $options controls what should and should not be displayed
177
     * @param formulas_part $part question part
178
     * @return stdClass
179
     */
180
    public function get_part_feedback_class_and_symbol(
181
        question_attempt $qa,
182
        question_display_options $options,
183
        formulas_part $part
184
    ): stdClass {
185
        // Prepare a new object to hold the different elements.
186
        $result = new stdClass();
121✔
187

188
        // Fetch the last response data and grade it.
189
        $response = $qa->get_last_qt_data();
121✔
190
        ['answer' => $answergrade, 'unit' => $unitcorrect] = $part->grade($response);
121✔
191

192
        // The fraction will later be used to determine which feedback (correct, partially correct or incorrect)
193
        // to use. We have to take into account a possible deduction for a wrong unit.
194
        $result->fraction = $answergrade;
121✔
195
        if ($unitcorrect === false) {
121✔
196
            $result->fraction *= (1 - $part->unitpenalty);
73✔
197
        }
198

199
        // By default, we add no feedback at all...
200
        $result->feedbacksymbol = '';
121✔
201
        $result->feedbackclass = '';
121✔
202
        // ... unless correctness is requested in the display options.
203
        // Note that no feedback should be given, if the response has been modified since the last submission,
204
        // i. e. it is just a response that was saved during page navigation.
205
        if ($this->response_is_same_as_submitted($qa, $part) && $options->correctness) {
121✔
206
            $result->feedbacksymbol = $this->feedback_image($result->fraction);
22✔
207
            $result->feedbackclass = $this->feedback_class($result->fraction);
22✔
208
        }
209
        return $result;
121✔
210
    }
211

212
    /**
213
     * Format given number according to numbering style, e. g. abc or 123.
214
     *
215
     * @param int $num number
216
     * @param string $style style to render the number in, acccording to {@see qtype_multichoice::get_numbering_styles()}
217
     * @return string number $num in the requested style
218
     */
219
    protected static function number_in_style(int $num, string $style): string {
220
        switch ($style) {
221
            case 'abc':
3✔
222
                $number = chr(ord('a') + $num);
2✔
223
                break;
2✔
224
            case 'ABCD':
1✔
225
                $number = chr(ord('A') + $num);
×
226
                break;
×
227
            case '123':
1✔
228
                $number = $num + 1;
×
229
                break;
×
230
            case 'iii':
1✔
231
                $number = question_utils::int_to_roman($num + 1);
×
232
                break;
×
233
            case 'IIII':
1✔
234
                $number = strtoupper(question_utils::int_to_roman($num + 1));
×
235
                break;
×
236
            case 'none':
1✔
237
                return '';
×
238
            default:
239
                // Default similar to none for compatibility with old questions.
240
                return '';
1✔
241
        }
242
        return $number . '. ';
2✔
243
    }
244

245
    /**
246
     * Create a set of radio boxes for a multiple choice answer input.
247
     *
248
     * @param formulas_part $part question part
249
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
250
     * @param question_attempt $qa question attempt that will be displayed on the page
251
     * @param array $answeroptions array of strings containing the answer options to choose from
252
     * @param bool $shuffle whether the options should be shuffled
253
     * @param question_display_options $displayoptions controls what should and should not be displayed
254
     * @param string $feedbackclass
255
     * @return string HTML fragment
256
     */
257
    protected function create_radio_mc_answer(
258
        formulas_part $part,
259
        $answerindex,
260
        question_attempt $qa,
261
        array $answeroptions,
262
        bool $shuffle,
263
        question_display_options $displayoptions,
264
        string $feedbackclass = ''
265
    ): string {
266
        /** @var qtype_formulas_question $question */
267
        $question = $qa->get_question();
3✔
268

269
        $variablename = "{$part->partindex}_{$answerindex}";
3✔
270
        $currentanswer = $qa->get_last_qt_var($variablename);
3✔
271
        $inputname = $qa->get_qt_field_name($variablename);
3✔
272

273
        $inputattributes['type'] = 'radio';
3✔
274
        $inputattributes['name'] = $inputname;
3✔
275
        if ($displayoptions->readonly) {
3✔
276
            $inputattributes['disabled'] = 'disabled';
1✔
277
        }
278

279
        // First, we open a <fieldset> around the entire group of options.
280
        $output = html_writer::start_tag('fieldset', ['class' => 'multichoice_answer']);
3✔
281

282
        // Inside the fieldset, we put the accessibility label, following the example of core's multichoice
283
        // question type, i. e. the label is inside a <span> with class 'sr-only', wrapped in a <legend>.
284
        // TODO: we should use visually-hidden after dropping Moodle 4.5.
285
        $output .= html_writer::start_tag('legend', ['class' => 'sr-only']);
3✔
286
        $output .= html_writer::span(
3✔
287
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
3✔
288
            'sr-only'
3✔
289
        );
3✔
290
        $output .= html_writer::end_tag('legend');
3✔
291

292
        // If needed, shuffle the options while maintaining the keys.
293
        if ($shuffle) {
3✔
294
            $keys = array_keys($answeroptions);
1✔
295
            shuffle($keys);
1✔
296

297
            $shuffledoptions = [];
1✔
298
            foreach ($keys as $key) {
1✔
299
                $shuffledoptions[$key] = $answeroptions[$key];
1✔
300
            }
301
            $answeroptions = $shuffledoptions;
1✔
302
        }
303

304
        // Iterate over all options.
305
        foreach ($answeroptions as $i => $optiontext) {
3✔
306
            $numbering = html_writer::span(self::number_in_style($i, $question->answernumbering), 'answernumber');
3✔
307
            $labeltext = $question->format_text(
3✔
308
                $numbering . $optiontext,
3✔
309
                $part->subqtextformat,
3✔
310
                $qa,
3✔
311
                'qtype_formulas',
3✔
312
                'answersubqtext',
3✔
313
                $part->id,
3✔
314
                false,
3✔
315
            );
3✔
316

317
            $inputattributes['id'] = $inputname . '_' . $i;
3✔
318
            $inputattributes['value'] = $i;
3✔
319
            // Class ml-3 is Bootstrap's class for margin-left: 1rem; it used to be m-l-1.
320
            $label = $this->create_label_for_input($labeltext, $inputattributes['id'], ['class' => 'ml-3']);
3✔
321
            $inputattributes['aria-labelledby'] = $label['id'];
3✔
322

323
            // We must make sure $currentanswer is not null, because otherwise the first radio box
324
            // might be selected if there is no answer at all. It seems better to avoid strict equality,
325
            // because we might compare a string to a number.
326
            $isselected = ($i == $currentanswer && !is_null($currentanswer));
3✔
327

328
            // We do not reset the $inputattributes array on each iteration, so we have to add/remove the
329
            // attribute every time.
330
            if ($isselected) {
3✔
331
                $inputattributes['checked'] = 'checked';
1✔
332
            } else {
333
                unset($inputattributes['checked']);
3✔
334
            }
335

336
            // Each option (radio box element plus label) is wrapped in its own <div> element.
337
            $divclass = 'r' . ($i % 2);
3✔
338
            if ($displayoptions->correctness && $isselected) {
3✔
339
                $divclass .= ' ' . $feedbackclass;
1✔
340
            }
341
            $output .= html_writer::start_div($divclass);
3✔
342

343
            // Now add the <input> tag and its <label>.
344
            $output .= html_writer::empty_tag('input', $inputattributes);
3✔
345
            $output .= $label['html'];
3✔
346

347
            // Close the option's <div>.
348
            $output .= html_writer::end_div();
3✔
349
        }
350

351
        // Close the option group's <fieldset>.
352
        $output .= html_writer::end_tag('fieldset');
3✔
353

354
        return $output;
3✔
355
    }
356

357
    /**
358
     * Translate an array containing formatting options into a CSS format string, e. g. from
359
     * ['w' => '50px', 'bgcol' => 'yellow'] to 'width: 50px; background-color: yellow'. Note:
360
     * - colors can be defined in 3 or 6 digit hex RGB, in 4 or 8 digit hex RGBA or as CSS named color
361
     * - widths can be defined as a number followed by the units px, rem or em; if the unit is omitted, rem will be used
362
     * - alignment can be defined as left, right, center, start or end
363
     *
364
     * @param array $options associative array containing options (in our own denomination) and their settings
365
     * @return string
366
     */
367
    protected function get_css_properties(array $options): string {
368
        // Define some regex pattern.
369
        $hexcolor = '#([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{3}|[0-9A-F]{4})';
117✔
370
        $namedcolor = '[A-Z]+';
117✔
371
        // We accept floating point numbers with or without a leading integer part and integers.
372
        // Floating point numbers with a trailing decimal point do not work in all browsers.
373
        $length = '(\d+\.\d+|\d*\.\d+|\d+)(px|em|rem)?';
117✔
374
        $alignment = 'start|end|left|right|center';
117✔
375

376
        $styles = [];
117✔
377
        foreach ($options as $name => $value) {
117✔
378
            switch ($name) {
379
                case 'bgcol':
117✔
380
                    if (!preg_match("/^(($hexcolor)|($namedcolor))$/i", $value)) {
29✔
381
                        break;
2✔
382
                    }
383
                    $styles[] = "background-color: $value";
27✔
384
                    break;
27✔
385
                case 'txtcol':
117✔
386
                    if (!preg_match("/^(($hexcolor)|($namedcolor))$/i", $value)) {
12✔
387
                        break;
2✔
388
                    }
389
                    $styles[] = "color: $value";
10✔
390
                    break;
10✔
391
                case 'w':
117✔
392
                    if (!preg_match("/^($length)$/i", $value)) {
117✔
393
                        break;
8✔
394
                    }
395
                    // If no unit is given, append rem.
396
                    $styles[] = "width: $value" . (preg_match('/\d$/', $value) ? 'rem' : '');
113✔
397
                    break;
113✔
398
                case 'align':
13✔
399
                    if (!preg_match("/^($alignment)$/i", $value)) {
13✔
400
                        break;
2✔
401
                    }
402
                    $styles[] = "text-align: $value";
11✔
403
                    break;
11✔
404
            }
405
        }
406

407
        return implode(';', $styles);
117✔
408
    }
409

410
    /**
411
     * Create a <label> element for a given input control (e. g. a text field). Returns the
412
     * HTML and the label's ID.
413
     *
414
     * @param string $text the label's text
415
     * @param string $inputid ID of the input control for which the label is created
416
     * @param array $additionalattributes possibility to add custom attributes, attribute name => value
417
     * @return array 'id' => label's ID to be used in 'aria-labelledby' attribute, 'html' => HTML code
418
     */
419
    protected function create_label_for_input(string $text, string $inputid, array $additionalattributes = []): array {
420
        $labelid = 'lbl_' . str_replace(':', '__', $inputid);
121✔
421
        $attributes = [
121✔
422
            'class' => 'subq sr-only',
121✔
423
            'for' => $inputid,
121✔
424
            'id' => $labelid,
121✔
425
        ];
121✔
426

427
        // Merging the additional attributes with the default attributes; left array has precedence when
428
        // using the + operator.
429
        $attributes = $additionalattributes + $attributes;
121✔
430

431
        return [
121✔
432
            'id' => $labelid,
121✔
433
            'html' => html_writer::tag('label', $text, $attributes),
121✔
434
        ];
121✔
435
    }
436

437
    /**
438
     * Create a <select> field for a multiple choice answer input.
439
     *
440
     * @param formulas_part $part question part
441
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
442
     * @param question_attempt $qa question attempt that will be displayed on the page
443
     * @param array $answeroptions array of strings containing the answer options to choose from
444
     * @param bool $shuffle whether the options should be shuffled
445
     * @param question_display_options $displayoptions controls what should and should not be displayed
446
     * @return string HTML fragment
447
     */
448
    protected function create_dropdown_mc_answer(
449
        formulas_part $part,
450
        $answerindex,
451
        question_attempt $qa,
452
        array $answeroptions,
453
        bool $shuffle,
454
        question_display_options $displayoptions
455
    ): string {
456
        /** @var qtype_formulas_question $question */
457
        $question = $qa->get_question();
3✔
458

459
        $variablename = "{$part->partindex}_{$answerindex}";
3✔
460
        $currentanswer = $qa->get_last_qt_var($variablename);
3✔
461
        $inputname = $qa->get_qt_field_name($variablename);
3✔
462

463
        $inputattributes['name'] = $inputname;
3✔
464
        $inputattributes['value'] = $currentanswer;
3✔
465
        $inputattributes['id'] = $inputname;
3✔
466
        $inputattributes['class'] = 'formulas-select';
3✔
467

468
        $label = $this->create_label_for_input(
3✔
469
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
3✔
470
            $inputname
3✔
471
        );
3✔
472
        $inputattributes['aria-labelledby'] = $label['id'];
3✔
473

474
        if ($displayoptions->readonly) {
3✔
475
            $inputattributes['disabled'] = 'disabled';
1✔
476
        }
477

478
        // First, we open a <span> around the dropdown field and its accessibility label.
479
        $output = html_writer::start_tag('span', ['class' => 'formulas_menu']);
3✔
480
        $output .= $label['html'];
3✔
481

482
        // Iterate over all options.
483
        $entries = [];
3✔
484
        foreach ($answeroptions as $optiontext) {
3✔
485
            $entries[] = $question->format_text(
3✔
486
                $optiontext,
3✔
487
                $part->subqtextformat,
3✔
488
                $qa,
3✔
489
                'qtype_formulas',
3✔
490
                'answersubqtext',
3✔
491
                $part->id,
3✔
492
                false,
3✔
493
            );
3✔
494
        }
495

496
        // If needed, shuffle the options while maintaining the keys.
497
        if ($shuffle) {
3✔
498
            $keys = array_keys($entries);
1✔
499
            shuffle($keys);
1✔
500

501
            $shuffledentries = [];
1✔
502
            foreach ($keys as $key) {
1✔
503
                $shuffledentries[$key] = $entries[$key];
1✔
504
            }
505
            $entries = $shuffledentries;
1✔
506
        }
507

508
        $output .= html_writer::select($entries, $inputname, $currentanswer, ['' => ''], $inputattributes);
3✔
509
        $output .= html_writer::end_tag('span');
3✔
510

511
        return $output;
3✔
512
    }
513

514
    /**
515
     * Generate the right label for the input control, depending on the number of answers in the given part
516
     * and the number of parts in the question. Special cases (combined field, unit field) are also taken
517
     * into account. Returns the appropriate string from the language file. Examples are "Answer field for
518
     * part X", "Answer field X for part Y" or "Answer and unit for part X".
519
     *
520
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
521
     * @param int $totalanswers number of answers for the given part
522
     * @param int $partindex number of the part (starting at 0) in this question
523
     * @param int $totalparts number of parts in the question
524
     * @return string localized string
525
     */
526
    protected function generate_accessibility_label_text(
527
        $answerindex,
528
        int $totalanswers,
529
        int $partindex,
530
        int $totalparts
531
    ): string {
532

533
        // Some language strings need parameters.
534
        $labeldata = new stdClass();
121✔
535

536
        // The language strings start with 'answerunit' for a separate unit field, 'answercombinedunit' for
537
        // a combined field, 'answercoordinate' for an answer field when there are multiple answers in the
538
        // part or just 'answer' if there is a single field.
539
        $labelstring = 'answer';
121✔
540
        if ($answerindex === self::UNIT_FIELD) {
121✔
541
            $labelstring .= 'unit';
37✔
542
        } else if ($answerindex === self::COMBINED_FIELD) {
121✔
543
            $labelstring .= 'combinedunit';
40✔
544
        } else if ($totalanswers > 1) {
100✔
545
            $labelstring .= 'coordinate';
3✔
546
            $labeldata->numanswer = $answerindex + 1;
3✔
547
        }
548

549
        // The language strings end with 'multi' for multi-part questions or 'single' for single-part
550
        // questions.
551
        if ($totalparts > 1) {
121✔
552
            $labelstring .= 'multi';
5✔
553
            $labeldata->part = $partindex + 1;
5✔
554
        } else {
555
            $labelstring .= 'single';
118✔
556
        }
557

558
        return get_string($labelstring, 'qtype_formulas', $labeldata);
121✔
559
    }
560

561
    /**
562
     * Create an <input> field.
563
     *
564
     * @param formulas_part $part question part
565
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
566
     * @param question_attempt $qa question attempt that will be displayed on the page
567
     * @param question_display_options $displayoptions controls what should and should not be displayed
568
     * @param array $formatoptions associative array 'optionname' => 'value', e. g. 'w' => '50px'
569
     * @param string $feedbackclass
570
     * @return string HTML fragment
571
     */
572
    protected function create_input_box(
573
        formulas_part $part,
574
        $answerindex,
575
        question_attempt $qa,
576
        question_display_options $displayoptions,
577
        array $formatoptions = [],
578
        string $feedbackclass = ''
579
    ): string {
580
        /** @var qtype_formulas_question $question */
581
        $question = $qa->get_question();
117✔
582

583
        // The variable name will be N_ for the (single) combined unit field of part N,
584
        // or N_M for answer #M in part #N. If #M is equal to the part's numbox (i. e. the
585
        // number of answers), it is a unit field; note that the fields are numbered starting
586
        // from 0, so with 3 answers, we have N_0, N_1, N_2 and only use N_3 if there is a
587
        // unit.
588
        $variablename = $part->partindex . '_';
117✔
589
        if ($answerindex === self::UNIT_FIELD) {
117✔
590
            $variablename .= $part->numbox;
37✔
591
        } else {
592
            $variablename .= ($answerindex === self::COMBINED_FIELD ? '' : $answerindex);
117✔
593
        }
594

595
        $currentanswer = $qa->get_last_qt_var($variablename);
117✔
596
        $inputname = $qa->get_qt_field_name($variablename);
117✔
597

598
        // Text fields will have a tooltip attached. The tooltip's content depends on the
599
        // answer type. Special tooltips exist for combined or separate unit fields.
600
        switch ($part->answertype) {
117✔
601
            case qtype_formulas::ANSWER_TYPE_NUMERIC:
602
                $titlestring = 'numeric';
6✔
603
                break;
6✔
604
            case qtype_formulas::ANSWER_TYPE_NUMERICAL_FORMULA:
605
                $titlestring = 'numerical_formula';
6✔
606
                break;
6✔
607
            case qtype_formulas::ANSWER_TYPE_ALGEBRAIC:
608
                $titlestring = 'algebraic_formula';
8✔
609
                break;
8✔
610
            case qtype_formulas::ANSWER_TYPE_NUMBER:
611
            default:
612
                $titlestring = 'number';
101✔
613
        }
614
        if ($answerindex === self::COMBINED_FIELD) {
117✔
615
            $titlestring .= '_unit';
40✔
616
        }
617
        if ($answerindex === self::UNIT_FIELD) {
117✔
618
            $titlestring = 'unit';
37✔
619
        }
620
        $title = get_string($titlestring, 'qtype_formulas');
117✔
621

622
        // Fetch the configured default width and use it, if the setting exists. Otherwise,
623
        // the plugin's default as defined in styles.css will be used. If the user did not
624
        // specify a unit, we use pixels (px). Note that this is different from the renderer
625
        // where rem is used in order to allow for the short syntax 'w=3' (3 chars wide).
626
        $defaultformat = [];
117✔
627
        $defaultwidth = get_config('qtype_formulas', "defaultwidth_{$titlestring}");
117✔
628
        // If the default width has not been set for the current answer box type, $defaultwidth will
629
        // be false and thus not numeric.
630
        if (is_numeric($defaultwidth)) {
117✔
631
            $defaultwidthunit = get_config('qtype_formulas', "defaultwidthunit");
117✔
632
            if (!in_array($defaultwidthunit, ['px', 'rem', 'em'])) {
117✔
633
                $defaultwidthunit = 'px';
8✔
634
            }
635
            $defaultformat = ['w' => $defaultwidth . $defaultwidthunit];
117✔
636
        }
637
        // Using the union operator the values from the left array will be kept.
638
        $formatoptions = $formatoptions + $defaultformat;
117✔
639

640
        $inputattributes = [
117✔
641
            'type' => 'text',
117✔
642
            'name' => $inputname,
117✔
643
            'value' => $currentanswer,
117✔
644
            'id' => $inputname,
117✔
645
            'style' => $this->get_css_properties($formatoptions),
117✔
646

647
            'data-answertype' => ($answerindex === self::UNIT_FIELD ? 'unit' : $part->answertype),
117✔
648
            'data-withunit' => ($answerindex === self::COMBINED_FIELD ? '1' : '0'),
117✔
649

650
            'title' => $title,
117✔
651
            'class' => "form-control formulas_{$titlestring} {$feedbackclass}",
117✔
652
            'maxlength' => 128,
117✔
653
        ];
117✔
654

655
        // If the answer type is "Number" and it is not a combined field, we only add the tooltip, if the
656
        // corresponding option is set.
657
        $iscombined = $inputattributes['data-withunit'] === '1';
117✔
658
        $isnumber = !$iscombined && $inputattributes['data-answertype'] === qtype_formulas::ANSWER_TYPE_NUMBER;
117✔
659
        $shownumbertooltip = get_config('qtype_formulas', 'shownumbertooltip');
117✔
660
        $inputattributes += [
117✔
661
            'data-qtype-formulas-enable-tooltip' => (!$isnumber || $shownumbertooltip ? 'true' : 'false'),
117✔
662
            'data-qtype-formulas-tooltip-trigger' => get_config('qtype_formulas', 'tooltiptrigger'),
117✔
663
        ];
117✔
664

665
        if ($displayoptions->readonly) {
117✔
666
            $inputattributes['readonly'] = 'readonly';
17✔
667
        }
668

669
        $label = $this->create_label_for_input(
117✔
670
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
117✔
671
            $inputname
117✔
672
        );
117✔
673
        $inputattributes['aria-labelledby'] = $label['id'];
117✔
674

675
        // We need to wrap our input field into a wrapper <div>, in order for the LaTeX preview
676
        // to be correctly positioned even inside a table.
677
        $output = $label['html'];
117✔
678
        $output .= html_writer::empty_tag('input', $inputattributes);
117✔
679

680
        return $output;
117✔
681
    }
682

683
    /**
684
     * Return the part's text with variables replaced by their values.
685
     *
686
     * @param question_attempt $qa question attempt that will be displayed on the page
687
     * @param question_display_options $options controls what should and should not be displayed
688
     * @param formulas_part $part question part
689
     * @param stdClass $sub class and symbol for the part feedback
690
     * @return string HTML fragment
691
     */
692
    public function get_part_formulation(
693
        question_attempt $qa,
694
        question_display_options $options,
695
        formulas_part $part,
696
        stdClass $sub
697
    ): string {
698
        /** @var qtype_formulas_question $question */
699
        $question = $qa->get_question();
121✔
700

701
        // Clone the part's evaluator and remove special variables like _0 etc., because they must
702
        // not be substituted here; otherwise, we would lose input boxes.
703
        $evaluator = clone $part->evaluator;
121✔
704
        $evaluator->remove_special_vars();
121✔
705
        $text = $evaluator->substitute_variables_in_text($part->subqtext);
121✔
706

707
        $subqreplaced = $question->format_text(
121✔
708
            $text,
121✔
709
            $part->subqtextformat,
121✔
710
            $qa,
121✔
711
            'qtype_formulas',
121✔
712
            'answersubqtext',
121✔
713
            $part->id,
121✔
714
            false,
121✔
715
        );
121✔
716

717
        // Get the set of defined placeholders and their options.
718
        $boxes = $part->scan_for_answer_boxes($subqreplaced);
121✔
719

720
        // Append missing placholders at the end of part. We do not put a space before the opening
721
        // or after the closing brace, in order to get {_0}{_u} for questions with one answer and
722
        // a unit. This makes sure that the question will receive a combined unit field.
723
        for ($i = 0; $i <= $part->numbox; $i++) {
121✔
724
            // If no unit has been set, we do not append the {_u} placeholder.
725
            if ($i == $part->numbox && empty($part->postunit)) {
121✔
726
                continue;
68✔
727
            }
728
            $placeholder = ($i == $part->numbox) ? '_u' : "_{$i}";
121✔
729
            // If the placeholder does not exist yet, we create it with default settings, i. e. no multi-choice
730
            // and no styling.
731
            if (!array_key_exists($placeholder, $boxes)) {
121✔
732
                $boxes[$placeholder] = [
30✔
733
                    'placeholder' => '{' . $placeholder . '}',
30✔
734
                    'options' => '',
30✔
735
                    'dropdown' => false,
30✔
736
                    'format' => [],
30✔
737
                ];
30✔
738
                $subqreplaced .= '{' . $placeholder . '}';
30✔
739
            }
740
        }
741

742
        // If part has combined unit answer input.
743
        if ($part->has_combined_unit_field()) {
121✔
744
            // For a combined unit field, we try to merge the formatting options from the {_0} and the
745
            // {_u} placeholder, giving precedence to the latter.
746
            $mergedformat = $boxes['_u']['format'] + $boxes['_0']['format'];
40✔
747
            $combinedfieldhtml = $this->create_input_box(
40✔
748
                $part,
40✔
749
                self::COMBINED_FIELD,
40✔
750
                $qa,
40✔
751
                $options,
40✔
752
                $mergedformat,
40✔
753
                $sub->feedbackclass,
40✔
754
            );
40✔
755
            // The combined field must be placed where the user has the {_0}{_u} placeholders, possibly with
756
            // their formatting options.
757
            $boxplaceholders = $boxes['_0']['placeholder'] . $boxes['_u']['placeholder'];
40✔
758
            return str_replace($boxplaceholders, $combinedfieldhtml, $subqreplaced);
40✔
759
        }
760

761
        // Iterate over all boxes again, this time creating the appropriate input control and insert it
762
        // at the position indicated by the placeholder.
763
        for ($i = 0; $i <= $part->numbox; $i++) {
100✔
764
            // For normal answer fields, the placeholder is {_N} with N being the number of the
765
            // answer, starting from 0. The unit field, if there is one, comes last and has the
766
            // {_u} placeholder.
767
            if ($i < $part->numbox) {
100✔
768
                $answerindex = $i;
100✔
769
                $placeholder = "_$i";
100✔
770
            } else if (!empty($part->postunit)) {
100✔
771
                $answerindex = self::UNIT_FIELD;
37✔
772
                $placeholder = '_u';
37✔
773
            }
774

775
            // If the user has requested a multi-choice element, they must have specified an array
776
            // variable containing the options. We try to fetch that variable. If this fails, we
777
            // simply continue and build a text field instead.
778
            $optiontexts = null;
100✔
779
            if (!empty($boxes[$placeholder]['options'])) {
100✔
780
                try {
781
                    $optiontexts = $part->evaluator->export_single_variable($boxes[$placeholder]['options']);
7✔
782
                } catch (Exception $e) {
1✔
783
                    // TODO: use non-capturing catch.
784
                    unset($e);
1✔
785
                }
786
            }
787

788
            if ($optiontexts === null) {
100✔
789
                $inputfieldhtml = $this->create_input_box(
96✔
790
                    $part,
96✔
791
                    $answerindex,
96✔
792
                    $qa,
96✔
793
                    $options,
96✔
794
                    $boxes[$placeholder]['format'],
96✔
795
                    $sub->feedbackclass,
96✔
796
                );
96✔
797
            } else if ($boxes[$placeholder]['dropdown']) {
6✔
798
                $inputfieldhtml = $this->create_dropdown_mc_answer(
3✔
799
                    $part,
3✔
800
                    $i,
3✔
801
                    $qa,
3✔
802
                    $optiontexts->value,
3✔
803
                    $boxes[$placeholder]['shuffle'],
3✔
804
                    $options,
3✔
805
                );
3✔
806
            } else {
807
                $inputfieldhtml = $this->create_radio_mc_answer(
3✔
808
                    $part,
3✔
809
                    $i,
3✔
810
                    $qa,
3✔
811
                    $optiontexts->value,
3✔
812
                    $boxes[$placeholder]['shuffle'],
3✔
813
                    $options,
3✔
814
                    $sub->feedbackclass,
3✔
815
                );
3✔
816
            }
817

818
            // The replacement text *might* contain a backslash and in the worst case this might
819
            // lead to an erroneous backreference, e. g. if the student's answer was \1. Thus,
820
            // we better use preg_replace_callback() instead of just preg_replace(), as this allows
821
            // us to ignore such unintentional backreferences.
822
            $subqreplaced = preg_replace_callback(
100✔
823
                '/' . preg_quote($boxes[$placeholder]['placeholder'], '/') . '/',
100✔
824
                function ($matches) use ($inputfieldhtml) {
100✔
825
                    return $inputfieldhtml;
100✔
826
                },
100✔
827
                $subqreplaced,
100✔
828
                1
100✔
829
            );
100✔
830
        }
831

832
        return $subqreplaced;
100✔
833
    }
834

835
    /**
836
     * Correct response for the question. This is not needed for the Formulas question, because
837
     * answers are relative to parts.
838
     *
839
     * @param question_attempt $qa question attempt that will be displayed on the page
840
     * @return string empty string
841
     */
842
    public function correct_response(question_attempt $qa) {
843
        return '';
18✔
844
    }
845

846
    /**
847
     * Generate an automatic description of the correct response for a given part.
848
     *
849
     * @param formulas_part $part question part
850
     * @return string HTML fragment
851
     */
852
    public function part_correct_response($part) {
853
        $answers = $part->get_correct_response(true);
18✔
854
        $answertext = implode('; ', $answers);
18✔
855

856
        $string = ($part->answernotunique ? 'correctansweris' : 'uniquecorrectansweris');
18✔
857
        return html_writer::nonempty_tag(
18✔
858
            'div',
18✔
859
            get_string($string, 'qtype_formulas', $answertext),
18✔
860
            ['class' => 'formulaspartcorrectanswer filter_mathjaxloader_equation'],
18✔
861
        );
18✔
862
    }
863

864
    /**
865
     * Check whether the last response of a question attempt is the same as the last submitted response, i. e. it
866
     * was either submitted (e. g. using the "Check" button) or it was saved during page navigation in a quiz but
867
     * still contains the same answers as the ones from the last regular submission.
868
     *
869
     * @param question_attempt $qa
870
     * @param formulas_part|null $part
871
     * @return bool
872
     */
873
    protected function response_is_same_as_submitted(question_attempt $qa, formulas_part|null $part = null): bool {
874
        // If the last step contains the behaviour var 'submit', it was itself a submitted response.
875
        $laststep = $qa->get_last_step();
121✔
876
        if ($laststep->has_behaviour_var('submit')) {
121✔
877
            return true;
23✔
878
        }
879

880
        // Otherwise, we try to fetch the step containing the last submitted response.
881
        $lastsubmitted = $qa->get_last_step_with_behaviour_var('submit');
119✔
882
        $lastsubmitteddata = $lastsubmitted->get_qt_data();
119✔
883

884
        // If there is no data, then no response has ever been submitted.
885
        if (empty($lastsubmitteddata)) {
119✔
886
            return false;
119✔
887
        }
888

889
        // If we have a part, we compare the last step's data to the one from the last submitted response,
890
        // but only for the fields of the relevant part.
891
        $lastdata = $laststep->get_qt_data();
2✔
892
        if ($part !== null) {
2✔
893
            return $part->is_same_response($lastsubmitteddata, $lastdata);
2✔
894
        }
895

896
        // If we do not have a part, we compare the reponse for the entire question.
897
        /** @var qtype_formulas_question $question */
898
        $question = $qa->get_question();
2✔
899

900
        return $question->is_same_response($lastsubmitteddata, $lastdata);
2✔
901
    }
902

903
    #[\Override]
904
    public function feedback(question_attempt $qa, question_display_options $options) {
905
        // We should not give feedback if the response is not properly submitted, but rather just saved
906
        // during navigation through the quiz.
907
        if (!$this->response_is_same_as_submitted($qa)) {
121✔
908
            return '';
119✔
909
        }
910

911
        return parent::feedback($qa, $options);
23✔
912
    }
913

914
    /**
915
     * Generate a brief statement of how many sub-parts of this question the
916
     * student got right.
917
     *
918
     * @param question_attempt $qa question attempt that will be displayed on the page
919
     * @return string HTML fragment
920
     */
921
    protected function num_parts_correct(question_attempt $qa) {
922
        /** @var qtype_formulas_question $question */
923
        $question = $qa->get_question();
12✔
924
        $response = $qa->get_last_qt_data();
12✔
925
        if (!$question->is_gradable_response($response)) {
12✔
UNCOV
926
            return '';
×
927
        }
928

929
        $numright = $question->get_num_parts_right($response)[0];
12✔
930
        if ($numright === 1) {
12✔
931
            return get_string('yougotoneright', 'qtype_formulas');
2✔
932
        } else {
933
            return get_string('yougotnright', 'qtype_formulas', $numright);
11✔
934
        }
935
    }
936

937
    /**
938
     * We need to owerwrite this method to replace global variables by their value.
939
     *
940
     * @param question_attempt $qa question attempt that will be displayed on the page
941
     * @param question_hint $hint the hint to be shown
942
     * @return string HTML fragment
943
     */
944
    protected function hint(question_attempt $qa, question_hint $hint) {
945
        /** @var qtype_formulas_question $question */
946
        $question = $qa->get_question();
2✔
947
        $hint->hint = $question->evaluator->substitute_variables_in_text($hint->hint);
2✔
948

949
        return html_writer::nonempty_tag('div', $question->format_hint($hint, $qa), ['class' => 'hint']);
2✔
950
    }
951

952
    /**
953
     * Generate HTML fragment for the question's combined feedback.
954
     *
955
     * @param question_attempt $qa question attempt that will be displayed on the page
956
     * @return string HTML fragment
957
     */
958
    protected function combined_feedback(question_attempt $qa) {
959
        /** @var qtype_formulas_question $question */
960
        $question = $qa->get_question();
23✔
961

962
        $state = $qa->get_state();
23✔
963
        if (!$state->is_finished()) {
23✔
964
            $response = $qa->get_last_qt_data();
7✔
965
            if (!$question->is_gradable_response($response)) {
7✔
UNCOV
966
                return '';
×
967
            }
968
            $state = $question->grade_response($response)[1];
7✔
969
        }
970

971
        // The feedback will be in ->correctfeedback, ->partiallycorrectfeedback or ->incorrectfeedback,
972
        // with the corresponding ->...feedbackformat setting. We create the property names here to simplify
973
        // access.
974
        $fieldname = $state->get_feedback_class() . 'feedback';
23✔
975
        $formatname = $state->get_feedback_class() . 'feedbackformat';
23✔
976

977
        // If there is no feedback, we return an empty string.
978
        if (strlen(trim($question->$fieldname)) === 0) {
23✔
979
            return '';
×
980
        }
981

982
        // Otherwise, we return the appropriate feedback. The text is run through format_text() to have
983
        // variables replaced.
984
        return $question->format_text(
23✔
985
            $question->$fieldname,
23✔
986
            $question->$formatname,
23✔
987
            $qa,
23✔
988
            'question',
23✔
989
            $fieldname,
23✔
990
            $question->id,
23✔
991
            false,
23✔
992
        );
23✔
993
    }
994

995
    /**
996
     * Generate the specific feedback. This is feedback that varies according to
997
     * the response the student gave.
998
     *
999
     * @param question_attempt $qa question attempt that will be displayed on the page
1000
     * @return string
1001
     */
1002
    public function specific_feedback(question_attempt $qa) {
1003
        return $this->combined_feedback($qa);
23✔
1004
    }
1005

1006
    /**
1007
     * Gereate the part's general feedback. This is feedback is shown to all students.
1008
     *
1009
     * @param question_attempt $qa question attempt that will be displayed on the page
1010
     * @param question_display_options $options controls what should and should not be displayed
1011
     * @param formulas_part $part question part
1012
     * @return string HTML fragment
1013
     */
1014
    protected function part_general_feedback(question_attempt $qa, question_display_options $options, formulas_part $part) {
1015
        /** @var qtype_formulas_question $question */
1016
        $question = $qa->get_question();
121✔
1017
        $state = $qa->get_state();
121✔
1018

1019
        // If no feedback should be shown, we return an empty string.
1020
        if (!$options->feedback) {
121✔
1021
            return '';
119✔
1022
        }
1023

1024
        // If we use the adaptive multipart behaviour, there will be some feedback about the grading,
1025
        // e. g. the obtained marks for this submission and the attracted penalty.
1026
        $gradingdetailsdiv = '';
23✔
1027
        if ($qa->get_behaviour_name() == 'adaptivemultipart') {
23✔
1028
            // This is rather a hack, but it will probably work.
1029
            $renderer = $this->page->get_renderer('qbehaviour_adaptivemultipart');
5✔
1030
            $details = $qa->get_behaviour()->get_part_mark_details($part->partindex);
5✔
1031
            $gradingdetailsdiv = $renderer->render_adaptive_marks($details, $options);
5✔
1032
            $state = $details->state;
5✔
1033
        }
1034
        // If the question is in a state that does not yet allow to give a feedback
1035
        // or if the response is not the last one to be checked, we return an empty string.
1036
        if (!$this->response_is_same_as_submitted($qa, $part) || empty($state->get_feedback_class())) {
23✔
1037
            return '';
2✔
1038
        }
1039

1040
        // If we have a general feedback, we substitute local / grading variables and
1041
        // wrap it in a <div>.
1042
        $feedbackdiv = '';
22✔
1043
        if (strlen(trim($part->feedback)) !== 0) {
22✔
1044
            $feedbacktext = $part->evaluator->substitute_variables_in_text($part->feedback);
13✔
1045
            $feedbacktext = $question->format_text(
13✔
1046
                $feedbacktext,
13✔
1047
                FORMAT_HTML,
13✔
1048
                $qa,
13✔
1049
                'qtype_formulas',
13✔
1050
                'answerfeedback',
13✔
1051
                $part->id,
13✔
1052
                false
13✔
1053
            );
13✔
1054
            $feedbackdiv = html_writer::tag('div', $feedbacktext, ['class' => 'feedback formulaslocalfeedback']);
13✔
1055
        }
1056

1057
        // Append the grading details, if they exist. If the result is not empty, wrap in
1058
        // a <div> and return.
1059
        $feedbackdiv .= $gradingdetailsdiv;
22✔
1060
        if (!empty($feedbackdiv)) {
22✔
1061
            return html_writer::nonempty_tag(
14✔
1062
                'div',
14✔
1063
                $feedbackdiv,
14✔
1064
                ['class' => 'formulaspartfeedback formulaspartfeedback-' . $part->partindex],
14✔
1065
            );
14✔
1066
        }
1067

1068
        // Still here? Then we return an empty string.
1069
        return '';
9✔
1070
    }
1071

1072
    /**
1073
     * Generate HTML fragment for the part's combined feedback.
1074
     *
1075
     * @param question_attempt $qa question attempt that will be displayed on the page
1076
     * @param question_display_options $options controls what should and should not be displayed
1077
     * @param formulas_part $part question part
1078
     * @param float $fraction the obtained grade
1079
     * @return string HTML fragment
1080
     */
1081
    protected function part_combined_feedback(
1082
        question_attempt $qa,
1083
        question_display_options $options,
1084
        formulas_part $part,
1085
        float $fraction
1086
    ): string {
1087
        $feedback = '';
121✔
1088
        $showfeedback = false;
121✔
1089
        /** @var qtype_formulas_question $question */
1090
        $question = $qa->get_question();
121✔
1091
        $state = $qa->get_state();
121✔
1092
        $feedbackclass = $state->get_feedback_class();
121✔
1093

1094
        if ($qa->get_behaviour_name() == 'adaptivemultipart') {
121✔
1095
            $details = $qa->get_behaviour()->get_part_mark_details($part->partindex);
5✔
1096
            $feedbackclass = $details->state->get_feedback_class();
5✔
1097
        } else {
1098
            $state = question_state::graded_state_for_fraction($fraction);
117✔
1099
            $feedbackclass = $state->get_feedback_class();
117✔
1100
        }
1101
        if ($feedbackclass != '') {
121✔
1102
            $showfeedback = $options->feedback;
121✔
1103
            $field = 'part' . $feedbackclass . 'fb';
121✔
1104
            $format = 'part' . $feedbackclass . 'fbformat';
121✔
1105
            if ($part->$field) {
121✔
1106
                // Clone the part's evaluator and substitute local / grading vars first.
1107
                $part->$field = $part->evaluator->substitute_variables_in_text($part->$field);
120✔
1108
                $feedback = $question->format_text(
120✔
1109
                    $part->$field,
120✔
1110
                    $part->$format,
120✔
1111
                    $qa,
120✔
1112
                    'qtype_formulas',
120✔
1113
                    $field,
120✔
1114
                    $part->id,
120✔
1115
                    false,
120✔
1116
                );
120✔
1117
            }
1118
        }
1119
        if ($showfeedback && $feedback) {
121✔
1120
                $feedback = html_writer::tag('div', $feedback, ['class' => 'feedback formulaslocalfeedback']);
22✔
1121
                return html_writer::nonempty_tag(
22✔
1122
                    'div',
22✔
1123
                    $feedback,
22✔
1124
                    ['class' => 'formulaspartfeedback formulaspartfeedback-' . $part->partindex],
22✔
1125
                );
22✔
1126
        }
1127
        return '';
119✔
1128
    }
1129
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc