74c216008c176d8bff07dc0ff3d0d238fc14faf4
[moodle.git] / lib / behat / form_field / behat_form_select.php
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/>.
17 /**
18  * Single select form field class.
19  *
20  * @package    core_form
21  * @category   test
22  * @copyright  2012 David MonllaĆ³
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
28 require_once(__DIR__  . '/behat_form_field.php');
30 /**
31  * Single select form field.
32  *
33  * @package    core_form
34  * @category   test
35  * @copyright  2012 David MonllaĆ³
36  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37  */
38 class behat_form_select extends behat_form_field {
40     /**
41      * Sets the value(s) of a select element.
42      *
43      * Seems an easy select, but there are lots of combinations
44      * of browsers and operative systems and each one manages the
45      * autosubmits and the multiple option selects in a different way.
46      *
47      * @param string $value plain value or comma separated values if multiple. Commas in values escaped with backslash.
48      * @return void
49      */
50     public function set_value($value) {
52         // In some browsers we select an option and it triggers all the
53         // autosubmits and works as expected but not in all of them, so we
54         // try to catch all the possibilities to make this function work as
55         // expected.
57         // Get the internal id of the element we are going to click.
58         // This kind of internal IDs are only available in the selenium wire
59         // protocol, so only available using selenium drivers, phantomjs and family.
60         if ($this->running_javascript()) {
61             $currentelementid = $this->get_internal_field_id();
62         }
64         // Is the select multiple?
65         $multiple = $this->field->hasAttribute('multiple');
67         // By default, assume the passed value is a non-multiple option.
68         $options = array(trim($value));
70         // Here we select the option(s).
71         if ($multiple) {
72             // Split and decode values. Comma separated list of values allowed. With valuable commas escaped with backslash.
73             $options = preg_replace('/\\\,/', ',',  preg_split('/(?<!\\\),/', $value));
74             // This is a multiple select, let's pass the multiple flag after first option.
75             $afterfirstoption = false;
76             foreach ($options as $option) {
77                 $this->field->selectOption(trim($option), $afterfirstoption);
78                 $afterfirstoption = true;
79             }
80         } else {
81             // This is a single select, let's pass the last one specified.
82             $this->field->selectOption(end($options));
83         }
85         // With JS disabled this is enough and we finish here.
86         if (!$this->running_javascript()) {
87             return;
88         }
90         // With JS enabled we add more clicks as some selenium
91         // drivers requires it to fire JS events.
93         // In some browsers the selectOption actions can perform a form submit or reload page
94         // so we need to ensure the element is still available to continue interacting
95         // with it. We don't wait here.
96         // getXpath() does not send a query to selenium, so we don't need to wrap it in a try & catch.
97         $selectxpath = $this->field->getXpath();
98         if (!$this->session->getDriver()->find($selectxpath)) {
99             return;
100         }
102         // We also check the selenium internal element id, if it have changed
103         // we are dealing with an autosubmit that was already executed, and we don't to
104         // execute anything else as the action we wanted was already performed.
105         if ($currentelementid != $this->get_internal_field_id()) {
106             return;
107         }
109         // Wait for all the possible AJAX requests that have been
110         // already triggered by selectOption() to be finished.
111         $this->session->wait(behat_base::TIMEOUT * 1000, behat_base::PAGE_READY_JS);
113         // Wrapped in try & catch as the element may disappear if an AJAX request was submitted.
114         try {
115             $multiple = $this->field->hasAttribute('multiple');
116         } catch (Exception $e) {
117             // We do not specify any specific Exception type as there are
118             // different exceptions that can be thrown by the driver and
119             // we can not control them all, also depending on the selenium
120             // version the exception type can change.
121             return;
122         }
124         // Single select sometimes needs an extra click in the option.
125         if (!$multiple) {
127             // $options only contains 1 option.
128             $optionxpath = $this->get_option_xpath(end($options), $selectxpath);
130             // Using the driver direcly because Element methods are messy when dealing
131             // with elements inside containers.
132             if ($optionnodes = $this->session->getDriver()->find($optionxpath)) {
134                 // Wrapped in a try & catch as we can fall into race conditions
135                 // and the element may not be there.
136                 try {
137                     current($optionnodes)->click();
138                 } catch (Exception $e) {
139                     // We continue and return as this means that the element is not there or it is not the same.
140                     return;
141                 }
142             }
144         } else {
146             // Wrapped in a try & catch as we can fall into race conditions
147             // and the element may not be there.
148             try {
149                 // Multiple ones needs the click in the select.
150                 $this->field->click();
151             } catch (Exception $e) {
152                 // We continue and return as this means that the element is not there or it is not the same.
153                 return;
154             }
156             // We also check that the option(s) are still there. We neither wait.
157             foreach ($options as $option) {
158                 $optionxpath = $this->get_option_xpath($option, $selectxpath);
159                 if (!$this->session->getDriver()->find($optionxpath)) {
160                     return;
161                 }
162             }
164             // Wait for all the possible AJAX requests that have been
165             // already triggered by clicking on the field to be finished.
166             $this->session->wait(behat_base::TIMEOUT * 1000, behat_base::PAGE_READY_JS);
168             // Wrapped in a try & catch as we can fall into race conditions
169             // and the element may not be there.
170             try {
172                 // Repeating the select(s) as some drivers (chrome that I know) are moving
173                 // to another option after the general select field click above.
174                 $afterfirstoption = false;
175                 foreach ($options as $option) {
176                     $this->field->selectOption(trim($option), $afterfirstoption);
177                     $afterfirstoption = true;
178                 }
179             } catch (Exception $e) {
180                 // We continue and return as this means that the element is not there or it is not the same.
181                 return;
182             }
183         }
184     }
186     /**
187      * Returns the text of the currently selected options.
188      *
189      * @return string Comma separated if multiple options are selected. Commas in option texts escaped with backslash.
190      */
191     public function get_value() {
192         return $this->get_selected_options();
193     }
195     /**
196      * Returns whether the provided argument matches the current value.
197      *
198      * @param mixed $expectedvalue
199      * @return bool
200      */
201     public function matches($expectedvalue) {
203         $multiple = $this->field->hasAttribute('multiple');
205         // Same implementation as the parent if it is a single select.
206         if (!$multiple) {
207             if (trim($expectedvalue) != trim($this->get_value())) {
208                 return false;
209             }
210             return true;
211         }
213         // We are dealing with a multi-select.
215         // Can pass multiple comma separated, with valuable commas escaped with backslash.
216         $expectedarr = array(); // Array of passed text options to test.
218         // Unescape + trim all options and flip it to have the expected values as keys.
219         $expectedoptions = $this->get_unescaped_options($expectedvalue);
221         // Get currently selected option's texts.
222         $texts = $this->get_selected_options(true);
223         $selectedoptiontexts = $this->get_unescaped_options($texts);
225         // Get currently selected option's values.
226         $values = $this->get_selected_options(false);
227         $selectedoptionvalues = $this->get_unescaped_options($values);
229         // Precheck to speed things up.
230         if (count($expectedoptions) !== count($selectedoptiontexts) ||
231                 count($expectedoptions) !== count($selectedoptionvalues)) {
232             return false;
233         }
235         // We check against string-ordered lists of options.
236         if ($expectedoptions != $selectedoptiontexts &&
237                 $expectedoptions != $selectedoptionvalues) {
238             return false;
239         }
241         return true;
242     }
244     /**
245      * Cleans the list of options and returns it as a string separating options with |||.
246      *
247      * @param string $value The string containing the escaped options.
248      * @return string The options
249      */
250     protected function get_unescaped_options($value) {
252         // Can be multiple comma separated, with valuable commas escaped with backslash.
253         $optionsarray = array_map(
254             'trim',
255             preg_replace('/\\\,/', ',',
256                 preg_split('/(?<!\\\),/', $value)
257            )
258         );
260         // Sort by value (keeping the keys is irrelevant).
261         core_collator::asort($optionsarray, SORT_STRING);
263         // Returning it as a string which is easier to match against other values.
264         return implode('|||', $optionsarray);
265     }
267     /**
268      * Returns the field selected values.
269      *
270      * Externalized from the common behat_form_field API method get_value() as
271      * matches() needs to check against both values and texts.
272      *
273      * @param bool $returntexts Returns the options texts or the options values.
274      * @return string
275      */
276     protected function get_selected_options($returntexts = true) {
278         $method = 'getText';
279         if ($returntexts === false) {
280             $method = 'getValue';
281         }
283         // Is the select multiple?
284         $multiple = $this->field->hasAttribute('multiple');
286         $selectedoptions = array(); // To accumulate found selected options.
288         // Selenium getValue() implementation breaks - separates - values having
289         // commas within them, so we'll be looking for options with the 'selected' attribute instead.
290         if ($this->running_javascript()) {
291             // Get all the options in the select and extract their value/text pairs.
292             $alloptions = $this->field->findAll('xpath', '//option');
293             foreach ($alloptions as $option) {
294                 // Is it selected?
295                 if ($option->hasAttribute('selected')) {
296                     if ($multiple) {
297                         // If the select is multiple, text commas must be encoded.
298                         $selectedoptions[] = trim(str_replace(',', '\,', $option->{$method}()));
299                     } else {
300                         $selectedoptions[] =  trim($option->{$method}());
301                     }
302                 }
303             }
305         // Goutte does not keep the 'selected' attribute updated, but its getValue() returns
306         // the selected elements correctly, also those having commas within them.
307         } else {
309             // Goutte returns the values as an array or as a string depending
310             // on whether multiple options are selected or not.
311             $values = $this->field->getValue();
312             if (!is_array($values)) {
313                 $values = array($values);
314             }
316             // Get all the options in the select and extract their value/text pairs.
317             $alloptions = $this->field->findAll('xpath', '//option');
318             foreach ($alloptions as $option) {
319                 // Is it selected?
320                 if (in_array($option->getValue(), $values)) {
321                     if ($multiple) {
322                         // If the select is multiple, text commas must be encoded.
323                         $selectedoptions[] = trim(str_replace(',', '\,', $option->{$method}()));
324                     } else {
325                         $selectedoptions[] =  trim($option->{$method}());
326                     }
327                 }
328             }
329         }
331         return implode(', ', $selectedoptions);
332     }
334     /**
335      * Returns the opton XPath based on it's select xpath.
336      *
337      * @param string $option
338      * @param string $selectxpath
339      * @return string xpath
340      */
341     protected function get_option_xpath($option, $selectxpath) {
342         $valueliteral = $this->session->getSelectorsHandler()->xpathLiteral(trim($option));
343         return $selectxpath . "/descendant::option[(./@value=$valueliteral or normalize-space(.)=$valueliteral)]";
344     }