Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 401 and 403]

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