1<?php
2// This file is part of Moodle - http://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 <http://www.gnu.org/licenses/>.
16
17/**
18 * Steps definitions for rubrics.
19 *
20 * @package   gradingform_rubric
21 * @category  test
22 * @copyright 2013 David Monllaó
23 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25
26// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
27
28require_once(__DIR__ . '/../../../../../../lib/behat/behat_base.php');
29
30use Behat\Gherkin\Node\TableNode as TableNode,
31    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException,
32    Behat\Mink\Exception\ExpectationException as ExpectationException;
33
34/**
35 * Steps definitions to help with rubrics.
36 *
37 * @package   gradingform_rubric
38 * @category  test
39 * @copyright 2013 David Monllaó
40 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
41 */
42class behat_gradingform_rubric extends behat_base {
43
44    /**
45     * @var The number of levels added by default when a rubric is created.
46     */
47    const DEFAULT_RUBRIC_LEVELS = 3;
48
49    /**
50     * Defines the rubric with the provided data, following rubric's definition grid cells.
51     *
52     * This method fills the rubric of the rubric definition
53     * form; the provided TableNode should contain one row for
54     * each criterion and each cell of the row should contain:
55     * # Criterion description
56     * # Criterion level 1 name
57     * # Criterion level 1 points
58     * # Criterion level 2 name
59     * # Criterion level 2 points
60     * # Criterion level 3 .....
61     *
62     * Works with both JS and non-JS.
63     *
64     * @When /^I define the following rubric:$/
65     * @throws ExpectationException
66     * @param TableNode $rubric
67     */
68    public function i_define_the_following_rubric(TableNode $rubric) {
69
70        // Being a smart method is nothing good when we talk about step definitions, in
71        // this case we didn't have any other options as there are no labels no elements
72        // id we can point to without having to "calculate" them.
73
74        $steptableinfo = '| criterion description | level1 name  | level1 points | level2 name | level2 points | ...';
75
76        $criteria = $rubric->getRows();
77
78        $addcriterionbutton = $this->find_button(get_string('addcriterion', 'gradingform_rubric'));
79
80        // Cleaning the current ones.
81        $deletebuttons = $this->find_all('css', "input[value='" . get_string('criteriondelete', 'gradingform_rubric') . "']");
82        if ($deletebuttons) {
83
84            // We should reverse the deletebuttons because otherwise once we delete
85            // the first one the DOM will change and the [X] one will not exist anymore.
86            $deletebuttons = array_reverse($deletebuttons, true);
87            foreach ($deletebuttons as $button) {
88                $this->click_and_confirm($button);
89            }
90        }
91
92        // The level number (NEWID$N) is not reset after each criterion.
93        $levelnumber = 1;
94
95        // The next criterion is created with the same number of levels than the last criterion.
96        $defaultnumberoflevels = self::DEFAULT_RUBRIC_LEVELS;
97
98        if ($criteria) {
99            foreach ($criteria as $criterionit => $criterion) {
100                // Unset empty levels in criterion.
101                foreach ($criterion as $i => $value) {
102                    if (empty($value)) {
103                        unset($criterion[$i]);
104                    }
105                }
106
107                // Remove empty criterion, as TableNode might contain them to make table rows equal size.
108                $newcriterion = array();
109                foreach ($criterion as $k => $c) {
110                    if (!empty($c)) {
111                        $newcriterion[$k] = $c;
112                    }
113                }
114                $criterion = $newcriterion;
115
116                // Checking the number of cells.
117                if (count($criterion) % 2 === 0) {
118                    throw new ExpectationException(
119                        'The criterion levels should contain both definition and points, follow this format:' . $steptableinfo,
120                        $this->getSession()
121                    );
122                }
123
124                // Minimum 2 levels per criterion.
125                // description + definition1 + score1 + definition2 + score2 = 5.
126                if (count($criterion) < 5) {
127                    throw new ExpectationException(
128                        get_string('err_mintwolevels', 'gradingform_rubric'),
129                        $this->getSession()
130                    );
131
132                }
133
134                // Add new criterion.
135                $addcriterionbutton->click();
136
137                $criterionroot = 'rubric[criteria][NEWID' . ($criterionit + 1) . ']';
138
139                // Getting the criterion description, this one is visible by default.
140                $this->set_rubric_field_value($criterionroot . '[description]', array_shift($criterion), true);
141
142                // When JS is disabled each criterion's levels name numbers starts from 0.
143                if (!$this->running_javascript()) {
144                    $levelnumber = 0;
145                }
146
147                // Setting the correct number of levels.
148                $nlevels = count($criterion) / 2;
149                if ($nlevels < $defaultnumberoflevels) {
150
151                    // Removing levels if there are too much levels.
152                    // When we add a new level the NEWID$N is increased from the last criterion.
153                    $lastcriteriondefaultlevel = $defaultnumberoflevels + $levelnumber - 1;
154                    $lastcriterionlevel = $nlevels + $levelnumber - 1;
155                    for ($i = $lastcriteriondefaultlevel; $i > $lastcriterionlevel; $i--) {
156
157                        // If JS is disabled seems that new levels are not added.
158                        if ($this->running_javascript()) {
159                            $deletelevel = $this->find_button($criterionroot . '[levels][NEWID' . $i . '][delete]');
160                            $this->click_and_confirm($deletelevel);
161
162                        } else {
163                            // Only if the level exists.
164                            $buttonname = $criterionroot . '[levels][NEWID' . $i . '][delete]';
165                            if ($deletelevel = $this->getSession()->getPage()->findButton($buttonname)) {
166                                $deletelevel->click();
167                            }
168                        }
169                    }
170                } else if ($nlevels > $defaultnumberoflevels) {
171                    // Adding levels if we don't have enough.
172                    $addlevel = $this->find_button($criterionroot . '[levels][addlevel]');
173                    for ($i = ($defaultnumberoflevels + 1); $i <= $nlevels; $i++) {
174                        $addlevel->click();
175                    }
176                }
177
178                // Updating it.
179                if ($nlevels > self::DEFAULT_RUBRIC_LEVELS) {
180                    $defaultnumberoflevels = $nlevels;
181                } else {
182                    // If it is less than the default value it sets it to
183                    // the default value.
184                    $defaultnumberoflevels = self::DEFAULT_RUBRIC_LEVELS;
185                }
186
187                foreach ($criterion as $i => $value) {
188
189                    $levelroot = $criterionroot . '[levels][NEWID' . $levelnumber . ']';
190
191                    if ($i % 2 === 0) {
192                        // Pairs are the definitions.
193                        $fieldname = $levelroot . '[definition]';
194                        $this->set_rubric_field_value($fieldname, $value);
195
196                    } else {
197                        // Odds are the points.
198
199                        // Checking it now, we would need to remove it if we are testing the form validations...
200                        if (!is_numeric($value)) {
201                            throw new ExpectationException(
202                                'The points cells should contain numeric values, follow this format: ' . $steptableinfo,
203                                $this->getSession()
204                            );
205                        }
206
207                        $fieldname = $levelroot . '[score]';
208                        $this->set_rubric_field_value($fieldname, $value, true);
209
210                        // Increase the level by one every 2 cells.
211                        $levelnumber++;
212                    }
213
214                }
215            }
216        }
217    }
218
219    /**
220     * Replaces a value from the specified criterion. You can use it when editing rubrics, to set both name or points.
221     *
222     * @When /^I replace "(?P<current_value_string>(?:[^"]|\\")*)" rubric level with "(?P<value_string>(?:[^"]|\\")*)" in "(?P<criterion_string>(?:[^"]|\\")*)" criterion$/
223     * @throws ElementNotFoundException
224     * @param string $currentvalue
225     * @param string $value
226     * @param string $criterionname
227     */
228    public function i_replace_rubric_level_with($currentvalue, $value, $criterionname) {
229
230        $currentvalueliteral = behat_context_helper::escape($currentvalue);
231        $criterionliteral = behat_context_helper::escape($criterionname);
232
233        $criterionxpath = "//div[@id='rubric-rubric']" .
234            "/descendant::td[contains(concat(' ', normalize-space(@class), ' '), ' description ')]";
235        // It differs between JS on/off.
236        if ($this->running_javascript()) {
237            $criterionxpath .= "/descendant::span[@class='textvalue'][text()=$criterionliteral]" .
238                "/ancestor::tr[contains(concat(' ', normalize-space(@class), ' '), ' criterion ')]";
239        } else {
240            $criterionxpath .= "/descendant::textarea[text()=$criterionliteral]" .
241                "/ancestor::tr[contains(concat(' ', normalize-space(@class), ' '), ' criterion ')]";
242        }
243
244        $inputxpath = $criterionxpath .
245            "/descendant::input[@type='text'][@value=$currentvalueliteral]";
246        $textareaxpath = $criterionxpath .
247            "/descendant::textarea[text()=$currentvalueliteral]";
248
249        if ($this->running_javascript()) {
250
251            $spansufix = "/ancestor::div[@class='level-wrapper']" .
252                "/descendant::div[@class='definition']" .
253                "/descendant::span[@class='textvalue']";
254
255            // Expanding the level input boxes.
256            $spannode = $this->find('xpath', $inputxpath . $spansufix . '|' . $textareaxpath . $spansufix);
257            $spannode->click();
258
259            $inputfield = $this->find('xpath', $inputxpath . '|' . $textareaxpath);
260            $inputfield->setValue($value);
261
262        } else {
263            $fieldnode = $this->find('xpath', $inputxpath . '|' . $textareaxpath);
264            $this->set_rubric_field_value($fieldnode->getAttribute('name'), $value);
265        }
266
267    }
268
269    /**
270     * Grades filling the current page rubric. Set one line per criterion and for each criterion set "| Criterion name | Points | Remark |".
271     *
272     * @When /^I grade by filling the rubric with:$/
273     *
274     * @throws ExpectationException
275     * @param TableNode $rubric
276     */
277    public function i_grade_by_filling_the_rubric_with(TableNode $rubric) {
278
279        $criteria = $rubric->getRowsHash();
280
281        $stepusage = '"I grade by filling the rubric with:" step needs you to provide a table where each row is a criterion' .
282            ' and each criterion has 3 different values: | Criterion name | Number of points | Remark text |';
283
284        // If running Javascript, ensure we zoom in before filling the grades.
285        if ($this->running_javascript()) {
286            $this->execute('behat_general::click_link', get_string('togglezoom', 'mod_assign'));
287        }
288
289        // First element -> name, second -> points, third -> Remark.
290        foreach ($criteria as $name => $criterion) {
291
292            // We only expect the points and the remark, as the criterion name is $name.
293            if (count($criterion) !== 2) {
294                throw new ExpectationException($stepusage, $this->getSession());
295            }
296
297            // Numeric value here.
298            $points = $criterion[0];
299            if (!is_numeric($points)) {
300                throw new ExpectationException($stepusage, $this->getSession());
301            }
302
303            // Selecting a value.
304            // When JS is disabled there are radio options, with JS enabled divs.
305            $selectedlevelxpath = $this->get_level_xpath($points);
306            if ($this->running_javascript()) {
307
308                // Only clicking on the selected level if it was not already selected.
309                $levelnode = $this->find('xpath', $selectedlevelxpath);
310
311                // Using in_array() as there are only a few elements.
312                if (!$levelnode->hasClass('checked')) {
313                    $levelnodexpath = $selectedlevelxpath . "//div[contains(concat(' ', normalize-space(@class), ' '), ' score ')]";
314                    $this->execute('behat_general::i_click_on_in_the',
315                        array($levelnodexpath, "xpath_element", $this->escape($name), "table_row")
316                    );
317                }
318
319            } else {
320
321                // Getting the name of the field.
322                $radioxpath = $this->get_criterion_xpath($name) .
323                    $selectedlevelxpath . "/descendant::input[@type='radio']";
324                $radionode = $this->find('xpath', $radioxpath);
325                // which will delegate the process to the field type.
326                $radionode->setValue($radionode->getAttribute('value'));
327            }
328
329            // Setting the remark.
330
331            // First we need to get the textarea name, then we can set the value.
332            $textarea = $this->get_node_in_container('css_element', 'textarea', 'table_row', $name);
333            $this->execute('behat_forms::i_set_the_field_to', array($textarea->getAttribute('name'), $criterion[1]));
334        }
335
336        // If running Javascript, then ensure to close zoomed rubric.
337        if ($this->running_javascript()) {
338            $this->execute('behat_general::click_link', get_string('togglezoom', 'mod_assign'));
339        }
340    }
341
342    /**
343     * Checks that the level was previously selected and the user changed to another level.
344     *
345     * @Then /^the level with "(?P<points_number>\d+)" points was previously selected for the rubric criterion "(?P<criterion_name_string>(?:[^"]|\\")*)"$/
346     * @throws ExpectationException
347     * @param string $criterionname
348     * @param int $points
349     * @return void
350     */
351    public function the_level_with_points_was_previously_selected_for_the_rubric_criterion($points, $criterionname) {
352
353        $levelxpath = $this->get_criterion_xpath($criterionname) .
354            $this->get_level_xpath($points) .
355            "[contains(concat(' ', normalize-space(@class), ' '), ' currentchecked ')]";
356
357        // Works both for JS and non-JS.
358        // - JS: Class -> checked is there when is marked as green.
359        // - Non-JS: When editing a rubric definition, there are radio inputs and when viewing a
360        //   grade @class contains checked.
361        $levelxpath .= "[not(contains(concat(' ', normalize-space(@class), ' '), ' checked '))]" .
362            "[not(/descendant::input[@type='radio'][@checked!='checked'])]";
363
364        try {
365            $this->find('xpath', $levelxpath);
366        } catch (ElementNotFoundException $e) {
367            throw new ExpectationException('"' . $points . '" points level was not previously selected', $this->getSession());
368        }
369    }
370
371    /**
372     * Checks that the level is currently selected. Works both when grading rubrics and viewing graded rubrics.
373     *
374     * @Then /^the level with "(?P<points_number>\d+)" points is selected for the rubric criterion "(?P<criterion_name_string>(?:[^"]|\\")*)"$/
375     * @throws ExpectationException
376     * @param string $criterionname
377     * @param int $points
378     * @return void
379     */
380    public function the_level_with_points_is_selected_for_the_rubric_criterion($points, $criterionname) {
381
382        $levelxpath = $this->get_criterion_xpath($criterionname) .
383            $this->get_level_xpath($points);
384
385        // Works both for JS and non-JS.
386        // - JS: Class -> checked is there when is marked as green.
387        // - Non-JS: When editing a rubric definition, there are radio inputs and when viewing a
388        //   grade @class contains checked.
389        $levelxpath .= "[" .
390            "contains(concat(' ', normalize-space(@class), ' '), ' checked ')" .
391            " or " .
392            "/descendant::input[@type='radio'][@checked='checked']" .
393            "]";
394
395        try {
396            $this->find('xpath', $levelxpath);
397        } catch (ElementNotFoundException $e) {
398            throw new ExpectationException('"' . $points . '" points level is not selected', $this->getSession());
399        }
400    }
401
402    /**
403     * Checks that the level is not currently selected. Works both when grading rubrics and viewing graded rubrics.
404     *
405     * @Then /^the level with "(?P<points_number>\d+)" points is not selected for the rubric criterion "(?P<criterion_name_string>(?:[^"]|\\")*)"$/
406     * @throws ExpectationException
407     * @param string $criterionname
408     * @param int $points
409     * @return void
410     */
411    public function the_level_with_points_is_not_selected_for_the_rubric_criterion($points, $criterionname) {
412
413        $levelxpath = $this->get_criterion_xpath($criterionname) .
414            $this->get_level_xpath($points);
415
416        // Works both for JS and non-JS.
417        // - JS: Class -> checked is there when is marked as green.
418        // - Non-JS: When editing a rubric definition, there are radio inputs and when viewing a
419        //   grade @class contains checked.
420        $levelxpath .= "[not(contains(concat(' ', normalize-space(@class), ' '), ' checked '))]" .
421            "[./descendant::input[@type='radio'][@checked!='checked'] or not(./descendant::input[@type='radio'])]";
422
423        try {
424            $this->find('xpath', $levelxpath);
425        } catch (ElementNotFoundException $e) {
426            throw new ExpectationException('"' . $points . '" points level is selected', $this->getSession());
427        }
428    }
429
430
431    /**
432     * Makes a hidden rubric field visible (if necessary) and sets a value on it.
433     *
434     * @param string $name The name of the field
435     * @param string $value The value to set
436     * @param bool $visible
437     * @return void
438     */
439    protected function set_rubric_field_value($name, $value, $visible = false) {
440
441        // Fields are hidden by default.
442        if ($this->running_javascript() == true && $visible === false) {
443            $xpath = "//*[@name='$name']/following-sibling::*[contains(concat(' ', normalize-space(@class), ' '), ' plainvalue ')]";
444            $textnode = $this->find('xpath', $xpath);
445            $textnode->click();
446        }
447
448        // Set the value now.
449        $description = $this->find_field($name);
450        $description->setValue($value);
451    }
452
453    /**
454     * Performs click confirming the action.
455     *
456     * @param NodeElement $node
457     * @return void
458     */
459    protected function click_and_confirm($node) {
460
461        // Clicks to perform the action.
462        $node->click();
463
464        // Confirms the delete.
465        if ($this->running_javascript()) {
466            $confirmbutton = $this->get_node_in_container(
467                'button',
468                get_string('yes'),
469                'dialogue',
470                get_string('confirmation', 'admin')
471            );
472            $confirmbutton->click();
473        }
474    }
475
476    /**
477     * Returns the xpath representing a selected level.
478     *
479     * It is not including the path to the criterion.
480     *
481     * It is the xpath when grading a rubric or viewing a rubric,
482     * it is not the same xpath when editing a rubric.
483     *
484     * @param int $points
485     * @return string
486     */
487    protected function get_level_xpath($points) {
488        return "//td[contains(concat(' ', normalize-space(@class), ' '), ' level ')]" .
489            "[./descendant::span[@class='scorevalue'][text()='$points']]";
490    }
491
492    /**
493     * Returns the xpath representing the selected criterion.
494     *
495     * It is the xpath when grading a rubric or viewing a rubric,
496     * it is not the same xpath when editing a rubric.
497     *
498     * @param string $criterionname Literal including the criterion name.
499     * @return string
500     */
501    protected function get_criterion_xpath($criterionname) {
502        $literal = behat_context_helper::escape($criterionname);
503        return "//tr[contains(concat(' ', normalize-space(@class), ' '), ' criterion ')]" .
504            "[./descendant::td[@class='description'][text()=$literal]]";
505    }
506}
507