Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Steps definitions related to mod_quiz.
 *
 * @package   mod_quiz
 * @category  test
 * @copyright 2014 Marina Glancy
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.

require_once(__DIR__ . '/../../../../lib/behat/behat_base.php');
require_once(__DIR__ . '/../../../../question/tests/behat/behat_question_base.php');

use Behat\Gherkin\Node\TableNode as TableNode;

use Behat\Mink\Exception\ExpectationException as ExpectationException;

/**
 * Steps definitions related to mod_quiz.
 *
 * @copyright 2014 Marina Glancy
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class behat_mod_quiz extends behat_question_base {

    /**
     * Convert page names to URLs for steps like 'When I am on the "[page name]" page'.
     *
     * Recognised page names are:
     * | None so far!      |                                                              |
     *
     * @param string $page name of the page, with the component name removed e.g. 'Admin notification'.
     * @return moodle_url the corresponding URL.
     * @throws Exception with a meaningful error message if the specified page cannot be found.
     */
    protected function resolve_page_url(string $page): moodle_url {
        switch (strtolower($page)) {
            default:
                throw new Exception('Unrecognised quiz page type "' . $page . '."');
        }
    }

    /**
     * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
     *
     * Recognised page names are:
     * | pagetype          | name meaning                                | description                                  |
     * | View              | Quiz name                                   | The quiz info page (view.php)                |
     * | Edit              | Quiz name                                   | The edit quiz page (edit.php)                |
     * | Group overrides   | Quiz name                                   | The manage group overrides page              |
     * | User overrides    | Quiz name                                   | The manage user overrides page               |
     * | Grades report     | Quiz name                                   | The overview report for a quiz               |
     * | Responses report  | Quiz name                                   | The responses report for a quiz              |
     * | Statistics report | Quiz name                                   | The statistics report for a quiz             |
     * | Attempt review    | Quiz name > username > [Attempt] attempt no | Review page for a given attempt (review.php) |
     *
     * @param string $type identifies which type of page this is, e.g. 'Attempt review'.
     * @param string $identifier identifies the particular page, e.g. 'Test quiz > student > Attempt 1'.
     * @return moodle_url the corresponding URL.
     * @throws Exception with a meaningful error message if the specified page cannot be found.
     */
    protected function resolve_page_instance_url(string $type, string $identifier): moodle_url {
        global $DB;

        switch (strtolower($type)) {
            case 'view':
                return new moodle_url('/mod/quiz/view.php',
                        ['id' => $this->get_cm_by_quiz_name($identifier)->id]);

            case 'edit':
                return new moodle_url('/mod/quiz/edit.php',
                        ['cmid' => $this->get_cm_by_quiz_name($identifier)->id]);

            case 'group overrides':
                return new moodle_url('/mod/quiz/overrides.php',
                    ['cmid' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'group']);

            case 'user overrides':
                return new moodle_url('/mod/quiz/overrides.php',
                    ['cmid' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'user']);

            case 'grades report':
                return new moodle_url('/mod/quiz/report.php',
                    ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'overview']);

            case 'responses report':
                return new moodle_url('/mod/quiz/report.php',
                    ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'responses']);

            case 'statistics report':
                return new moodle_url('/mod/quiz/report.php',
                    ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'statistics']);

            case 'manual grading report':
                return new moodle_url('/mod/quiz/report.php',
                        ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'grading']);
            case 'attempt view':
                list($quizname, $username, $attemptno, $pageno) = explode(' > ', $identifier);
                $pageno = intval($pageno);
                $pageno = $pageno > 0 ? $pageno - 1 : 0;
                $attemptno = (int) trim(str_replace ('Attempt', '', $attemptno));
                $quiz = $this->get_quiz_by_name($quizname);
                $quizcm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course);
                $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
                $attempt = $DB->get_record('quiz_attempts',
                    ['quiz' => $quiz->id, 'userid' => $user->id, 'attempt' => $attemptno], '*', MUST_EXIST);
                return new moodle_url('/mod/quiz/attempt.php', [
                    'attempt' => $attempt->id,
                    'cmid' => $quizcm->id,
                    'page' => $pageno
                ]);
            case 'attempt review':
                if (substr_count($identifier, ' > ') !== 2) {
                    throw new coding_exception('For "attempt review", name must be ' .
                            '"{Quiz name} > {username} > Attempt {attemptnumber}", ' .
                            'for example "Quiz 1 > student > Attempt 1".');
                }
                list($quizname, $username, $attemptno) = explode(' > ', $identifier);
                $attemptno = (int) trim(str_replace ('Attempt', '', $attemptno));
                $quiz = $this->get_quiz_by_name($quizname);
                $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
                $attempt = $DB->get_record('quiz_attempts',
                        ['quiz' => $quiz->id, 'userid' => $user->id, 'attempt' => $attemptno], '*', MUST_EXIST);
                return new moodle_url('/mod/quiz/review.php', ['attempt' => $attempt->id]);

            default:
                throw new Exception('Unrecognised quiz page type "' . $type . '."');
        }
    }

    /**
     * Get a quiz by name.
     *
     * @param string $name quiz name.
     * @return stdClass the corresponding DB row.
     */
    protected function get_quiz_by_name(string $name): stdClass {
        global $DB;
        return $DB->get_record('quiz', array('name' => $name), '*', MUST_EXIST);
    }

    /**
     * Get a quiz cmid from the quiz name.
     *
     * @param string $name quiz name.
     * @return stdClass cm from get_coursemodule_from_instance.
     */
    protected function get_cm_by_quiz_name(string $name): stdClass {
        $quiz = $this->get_quiz_by_name($name);
        return get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course);
    }

    /**
     * Put the specified questions on the specified pages of a given quiz.
     *
     * The first row should be column names:
     * | question | page | maxmark | requireprevious |
     * The first two of those are required. The others are optional.
     *
     * question        needs to uniquely match a question name.
     * page            is a page number. Must start at 1, and on each following
     *                 row should be the same as the previous, or one more.
     * maxmark         What the question is marked out of. Defaults to question.defaultmark.
     * requireprevious The question can only be attempted after the previous one was completed.
     *
     * Then there should be a number of rows of data, one for each question you want to add.
     *
     * For backwards-compatibility reasons, specifying the column names is optional
     * (but strongly encouraged). If not specified, the columns are asseumed to be
     * | question | page | maxmark |.
     *
     * @param string $quizname the name of the quiz to add questions to.
     * @param TableNode $data information about the questions to add.
     *
     * @Given /^quiz "([^"]*)" contains the following questions:$/
     */
    public function quiz_contains_the_following_questions($quizname, TableNode $data) {
        global $DB;

        $quiz = $this->get_quiz_by_name($quizname);

        // Deal with backwards-compatibility, optional first row.
        $firstrow = $data->getRow(0);
        if (!in_array('question', $firstrow) && !in_array('page', $firstrow)) {
            if (count($firstrow) == 2) {
                $headings = array('question', 'page');
            } else if (count($firstrow) == 3) {
                $headings = array('question', 'page', 'maxmark');
            } else {
                throw new ExpectationException('When adding questions to a quiz, you should give 2 or three 3 things: ' .
                        ' the question name, the page number, and optionally the maximum mark. ' .
                        count($firstrow) . ' values passed.', $this->getSession());
            }
            $rows = $data->getRows();
            array_unshift($rows, $headings);
            $data = new TableNode($rows);
        }

        // Add the questions.
        $lastpage = 0;
        foreach ($data->getHash() as $questiondata) {
            if (!array_key_exists('question', $questiondata)) {
                throw new ExpectationException('When adding questions to a quiz, ' .
                        'the question name column is required.', $this->getSession());
            }
            if (!array_key_exists('page', $questiondata)) {
                throw new ExpectationException('When adding questions to a quiz, ' .
                        'the page number column is required.', $this->getSession());
            }

            // Question id, category and type.
            $question = $DB->get_record('question', array('name' => $questiondata['question']), 'id, category, qtype', MUST_EXIST);

            // Page number.
            $page = clean_param($questiondata['page'], PARAM_INT);
            if ($page <= 0 || (string) $page !== $questiondata['page']) {
                throw new ExpectationException('The page number for question "' .
                         $questiondata['question'] . '" must be a positive integer.',
                        $this->getSession());
            }
            if ($page < $lastpage || $page > $lastpage + 1) {
                throw new ExpectationException('When adding questions to a quiz, ' .
                        'the page number for each question must either be the same, ' .
                        'or one more, then the page number for the previous question.',
                        $this->getSession());
            }
            $lastpage = $page;

            // Max mark.
            if (!array_key_exists('maxmark', $questiondata) || $questiondata['maxmark'] === '') {
                $maxmark = null;
            } else {
< $maxmark = clean_param($questiondata['maxmark'], PARAM_FLOAT); < if (!is_numeric($questiondata['maxmark']) || $maxmark < 0) {
> $maxmark = clean_param($questiondata['maxmark'], PARAM_LOCALISEDFLOAT); > if (!is_numeric($maxmark) || $maxmark < 0) {
throw new ExpectationException('The max mark for question "' . $questiondata['question'] . '" must be a positive number.', $this->getSession()); } } if ($question->qtype == 'random') { if (!array_key_exists('includingsubcategories', $questiondata) || $questiondata['includingsubcategories'] === '') { $includingsubcategories = false; } else { $includingsubcategories = clean_param($questiondata['includingsubcategories'], PARAM_BOOL); } quiz_add_random_questions($quiz, $page, $question->category, 1, $includingsubcategories); } else { // Add the question. quiz_add_quiz_question($question->id, $quiz, $page, $maxmark); } // Require previous. if (array_key_exists('requireprevious', $questiondata)) { if ($questiondata['requireprevious'] === '1') { $slot = $DB->get_field('quiz_slots', 'MAX(slot)', array('quizid' => $quiz->id)); $DB->set_field('quiz_slots', 'requireprevious', 1, array('quizid' => $quiz->id, 'slot' => $slot)); } else if ($questiondata['requireprevious'] !== '' && $questiondata['requireprevious'] !== '0') { throw new ExpectationException('Require previous for question "' . $questiondata['question'] . '" should be 0, 1 or blank.', $this->getSession()); } } } quiz_update_sumgrades($quiz); } /** * Put the specified section headings to start at specified pages of a given quiz. * * The first row should be column names: * | heading | firstslot | shufflequestions | * * heading is the section heading text * firstslot is the slot number where the section starts * shuffle whether this section is shuffled (0 or 1) * * Then there should be a number of rows of data, one for each section you want to add. * * @param string $quizname the name of the quiz to add sections to. * @param TableNode $data information about the sections to add. * * @Given /^quiz "([^"]*)" contains the following sections:$/ */ public function quiz_contains_the_following_sections($quizname, TableNode $data) { global $DB; $quiz = $DB->get_record('quiz', array('name' => $quizname), '*', MUST_EXIST); // Add the sections. $previousfirstslot = 0; foreach ($data->getHash() as $rownumber => $sectiondata) { if (!array_key_exists('heading', $sectiondata)) { throw new ExpectationException('When adding sections to a quiz, ' . 'the heading name column is required.', $this->getSession()); } if (!array_key_exists('firstslot', $sectiondata)) { throw new ExpectationException('When adding sections to a quiz, ' . 'the firstslot name column is required.', $this->getSession()); } if (!array_key_exists('shuffle', $sectiondata)) { throw new ExpectationException('When adding sections to a quiz, ' . 'the shuffle name column is required.', $this->getSession()); } if ($rownumber == 0) { $section = $DB->get_record('quiz_sections', array('quizid' => $quiz->id), '*', MUST_EXIST); } else { $section = new stdClass(); $section->quizid = $quiz->id; } // Heading. $section->heading = $sectiondata['heading']; // First slot. $section->firstslot = clean_param($sectiondata['firstslot'], PARAM_INT); if ($section->firstslot <= $previousfirstslot || (string) $section->firstslot !== $sectiondata['firstslot']) { throw new ExpectationException('The firstslot number for section "' . $sectiondata['heading'] . '" must an integer greater than the previous section firstslot.', $this->getSession()); } if ($rownumber == 0 && $section->firstslot != 1) { throw new ExpectationException('The first section must have firstslot set to 1.', $this->getSession()); } // Shuffle. $section->shufflequestions = clean_param($sectiondata['shuffle'], PARAM_INT); if ((string) $section->shufflequestions !== $sectiondata['shuffle']) { throw new ExpectationException('The shuffle value for section "' . $sectiondata['heading'] . '" must be 0 or 1.', $this->getSession()); } if ($rownumber == 0) { $DB->update_record('quiz_sections', $section); } else { $DB->insert_record('quiz_sections', $section); } } if ($section->firstslot > $DB->count_records('quiz_slots', array('quizid' => $quiz->id))) { throw new ExpectationException('The section firstslot must be less than the total number of slots in the quiz.', $this->getSession()); } } /** * Adds a question to the existing quiz with filling the form. * * The form for creating a question should be on one page. * * @When /^I add a "(?P<question_type_string>(?:[^"]|\\")*)" question to the "(?P<quiz_name_string>(?:[^"]|\\")*)" quiz with:$/ * @param string $questiontype * @param string $quizname * @param TableNode $questiondata with data for filling the add question form */ public function i_add_question_to_the_quiz_with($questiontype, $quizname, TableNode $questiondata) { $quizname = $this->escape($quizname); $addaquestion = $this->escape(get_string('addaquestion', 'quiz')); $this->execute('behat_navigation::i_am_on_page_instance', [ $quizname, 'mod_quiz > Edit', ]); if ($this->running_javascript()) { $this->execute("behat_action_menu::i_open_the_action_menu_in", array('.slots', "css_element")); $this->execute("behat_action_menu::i_choose_in_the_open_action_menu", array($addaquestion)); } else { $this->execute('behat_general::click_link', $addaquestion); } $this->finish_adding_question($questiontype, $questiondata); } /** * Set the max mark for a question on the Edit quiz page. * * @When /^I set the max mark for question "(?P<question_name_string>(?:[^"]|\\")*)" to "(?P<new_mark_string>(?:[^"]|\\")*)"$/ * @param string $questionname the name of the question to set the max mark for. * @param string $newmark the mark to set */ public function i_set_the_max_mark_for_quiz_question($questionname, $newmark) { $this->execute('behat_general::click_link', $this->escape(get_string('editmaxmark', 'quiz'))); $this->execute('behat_general::wait_until_exists', array("li input[name=maxmark]", "css_element")); $this->execute('behat_general::assert_page_contains_text', $this->escape(get_string('edittitleinstructions'))); $this->execute('behat_general::i_type', [$newmark]); $this->execute('behat_general::i_press_named_key', ['', 'enter']); } /** * Open the add menu on a given page, or at the end of the Edit quiz page. * @Given /^I open the "(?P<page_n_or_last_string>(?:[^"]|\\")*)" add to quiz menu$/ * @param string $pageorlast either "Page n" or "last". */ public function i_open_the_add_to_quiz_menu_for($pageorlast) { if (!$this->running_javascript()) { throw new DriverException('Activities actions menu not available when Javascript is disabled'); } if ($pageorlast == 'last') { $xpath = "//div[@class = 'last-add-menu']//a[contains(@data-toggle, 'dropdown') and contains(., 'Add')]"; } else if (preg_match('~Page (\d+)~', $pageorlast, $matches)) { $xpath = "//li[@id = 'page-{$matches[1]}']//a[contains(@data-toggle, 'dropdown') and contains(., 'Add')]"; } else { throw new ExpectationException("The I open the add to quiz menu step must specify either 'Page N' or 'last'.", $this->getSession()); } $this->find('xpath', $xpath)->click(); } /** * Check whether a particular question is on a particular page of the quiz on the Edit quiz page. * @Given /^I should see "(?P<question_name>(?:[^"]|\\")*)" on quiz page "(?P<page_number>\d+)"$/ * @param string $questionname the name of the question we are looking for. * @param number $pagenumber the page it should be found on. */ public function i_should_see_on_quiz_page($questionname, $pagenumber) { $xpath = "//li[contains(., '" . $this->escape($questionname) . "')][./preceding-sibling::li[contains(@class, 'pagenumber')][1][contains(., 'Page " . $pagenumber . "')]]"; $this->execute('behat_general::should_exist', array($xpath, 'xpath_element')); } /** * Check whether a particular question is not on a particular page of the quiz on the Edit quiz page. * @Given /^I should not see "(?P<question_name>(?:[^"]|\\")*)" on quiz page "(?P<page_number>\d+)"$/ * @param string $questionname the name of the question we are looking for. * @param number $pagenumber the page it should be found on. */ public function i_should_not_see_on_quiz_page($questionname, $pagenumber) { $xpath = "//li[contains(., '" . $this->escape($questionname) . "')][./preceding-sibling::li[contains(@class, 'pagenumber')][1][contains(., 'Page " . $pagenumber . "')]]"; $this->execute('behat_general::should_not_exist', array($xpath, 'xpath_element')); } /** * Check whether one question comes before another on the Edit quiz page. * The two questions must be on the same page. * @Given /^I should see "(?P<first_q_name>(?:[^"]|\\")*)" before "(?P<second_q_name>(?:[^"]|\\")*)" on the edit quiz page$/ * @param string $firstquestionname the name of the question that should come first in order. * @param string $secondquestionname the name of the question that should come immediately after it in order. */ public function i_should_see_before_on_the_edit_quiz_page($firstquestionname, $secondquestionname) { $xpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($firstquestionname) . "')]/following-sibling::li[contains(@class, ' slot ')][1]" . "[contains(., '" . $this->escape($secondquestionname) . "')]"; $this->execute('behat_general::should_exist', array($xpath, 'xpath_element')); } /** * Check the number displayed alongside a question on the Edit quiz page. * @Given /^"(?P<question_name>(?:[^"]|\\")*)" should have number "(?P<number>(?:[^"]|\\")*)" on the edit quiz page$/ * @param string $questionname the name of the question we are looking for. * @param number $number the number (or 'i') that should be displayed beside that question. */ public function should_have_number_on_the_edit_quiz_page($questionname, $number) { $xpath = "//li[contains(@class, 'slot') and contains(., '" . $this->escape($questionname) . "')]//span[contains(@class, 'slotnumber') and normalize-space(text()) = '" . $this->escape($number) . "']"; $this->execute('behat_general::should_exist', array($xpath, 'xpath_element')); } /** * Get the xpath for a partcular add/remove page-break icon. * @param string $addorremoves 'Add' or 'Remove'. * @param string $questionname the name of the question before the icon. * @return string the requried xpath. */ protected function get_xpath_page_break_icon_after_question($addorremoves, $questionname) { return "//li[contains(@class, 'slot') and contains(., '" . $this->escape($questionname) . "')]//a[contains(@class, 'page_split_join') and @title = '" . $addorremoves . " page break']"; } /** * Click the add or remove page-break icon after a particular question. * @When /^I click on the "(Add|Remove)" page break icon after question "(?P<question_name>(?:[^"]|\\")*)"$/ * @param string $addorremoves 'Add' or 'Remove'. * @param string $questionname the name of the question before the icon to click. */ public function i_click_on_the_page_break_icon_after_question($addorremoves, $questionname) { $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); $this->execute("behat_general::i_click_on", array($xpath, "xpath_element")); } /** * Assert the add or remove page-break icon after a particular question exists. * @When /^the "(Add|Remove)" page break icon after question "(?P<question_name>(?:[^"]|\\")*)" should exist$/ * @param string $addorremoves 'Add' or 'Remove'. * @param string $questionname the name of the question before the icon to click. * @return array of steps. */ public function the_page_break_icon_after_question_should_exist($addorremoves, $questionname) { $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); $this->execute('behat_general::should_exist', array($xpath, 'xpath_element')); } /** * Assert the add or remove page-break icon after a particular question does not exist. * @When /^the "(Add|Remove)" page break icon after question "(?P<question_name>(?:[^"]|\\")*)" should not exist$/ * @param string $addorremoves 'Add' or 'Remove'. * @param string $questionname the name of the question before the icon to click. * @return array of steps. */ public function the_page_break_icon_after_question_should_not_exist($addorremoves, $questionname) { $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); $this->execute('behat_general::should_not_exist', array($xpath, 'xpath_element')); } /** * Check the add or remove page-break link after a particular question contains the given parameters in its url. * * @When /^the "(Add|Remove)" page break link after question "(?P<question_name>(?:[^"]|\\")*) should contain:$/ * @When /^the "(Add|Remove)" page break link after question "(?P<question_name>(?:[^"]|\\")*) should contain:"$/ * @param string $addorremoves 'Add' or 'Remove'. * @param string $questionname the name of the question before the icon to click. * @param TableNode $paramdata with data for checking the page break url * @return array of steps. */ public function the_page_break_link_after_question_should_contain($addorremoves, $questionname, $paramdata) { $xpath = $this->get_xpath_page_break_icon_after_question($addorremoves, $questionname); $this->execute("behat_general::i_click_on", array($xpath, "xpath_element")); } /** * Set Shuffle for shuffling questions within sections * * @param string $heading the heading of the section to change shuffle for. * * @Given /^I click on shuffle for section "([^"]*)" on the quiz edit page$/ */ public function i_click_on_shuffle_for_section($heading) { $xpath = $this->get_xpath_for_shuffle_checkbox($heading); $checkbox = $this->find('xpath', $xpath); $this->ensure_node_is_visible($checkbox); $checkbox->click(); } /** * Check the shuffle checkbox for a particular section. * * @param string $heading the heading of the section to check shuffle for * @param int $value whether the shuffle checkbox should be on or off. * * @Given /^shuffle for section "([^"]*)" should be "(On|Off)" on the quiz edit page$/ */ public function shuffle_for_section_should_be($heading, $value) { $xpath = $this->get_xpath_for_shuffle_checkbox($heading); $checkbox = $this->find('xpath', $xpath); $this->ensure_node_is_visible($checkbox); if ($value == 'On' && !$checkbox->isChecked()) { $msg = "Shuffle for section '$heading' is not checked, but you are expecting it to be checked ($value). " . "Check the line with: \nshuffle for section \"$heading\" should be \"$value\" on the quiz edit page" . "\nin your behat script"; throw new ExpectationException($msg, $this->getSession()); } else if ($value == 'Off' && $checkbox->isChecked()) { $msg = "Shuffle for section '$heading' is checked, but you are expecting it not to be ($value). " . "Check the line with: \nshuffle for section \"$heading\" should be \"$value\" on the quiz edit page" . "\nin your behat script"; throw new ExpectationException($msg, $this->getSession()); } } /** * Return the xpath for shuffle checkbox in section heading * @param string $heading * @return string */ protected function get_xpath_for_shuffle_checkbox($heading) { return "//div[contains(@class, 'section-heading') and contains(., '" . $this->escape($heading) . "')]//input[@type = 'checkbox']"; } /** * Move a question on the Edit quiz page by first clicking on the Move icon, * then clicking one of the "After ..." links. * @When /^I move "(?P<question_name>(?:[^"]|\\")*)" to "(?P<target>(?:[^"]|\\")*)" in the quiz by clicking the move icon$/ * @param string $questionname the name of the question we are looking for. * @param string $target the target place to move to. One of the links in the pop-up like * "After Page 1" or "After Question N". */ public function i_move_question_after_item_by_clicking_the_move_icon($questionname, $target) { $iconxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) . "')]//span[contains(@class, 'editing_move')]"; $this->execute("behat_general::i_click_on", array($iconxpath, "xpath_element")); $this->execute("behat_general::i_click_on", array($this->escape($target), "text")); } /** * Move a question on the Edit quiz page by dragging a given question on top of another item. * @When /^I move "(?P<question_name>(?:[^"]|\\")*)" to "(?P<target>(?:[^"]|\\")*)" in the quiz by dragging$/ * @param string $questionname the name of the question we are looking for. * @param string $target the target place to move to. Ether a question name, or "Page N" */ public function i_move_question_after_item_by_dragging($questionname, $target) { $iconxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) . "')]//span[contains(@class, 'editing_move')]//img"; $destinationxpath = "//li[contains(@class, ' slot ') or contains(@class, 'pagenumber ')]" . "[contains(., '" . $this->escape($target) . "')]"; $this->execute('behat_general::i_drag_and_i_drop_it_in', array($iconxpath, 'xpath_element', $destinationxpath, 'xpath_element') ); } /** * Delete a question on the Edit quiz page by first clicking on the Delete icon, * then clicking one of the "After ..." links. * @When /^I delete "(?P<question_name>(?:[^"]|\\")*)" in the quiz by clicking the delete icon$/ * @param string $questionname the name of the question we are looking for. * @return array of steps. */ public function i_delete_question_by_clicking_the_delete_icon($questionname) { $slotxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) . "')]"; $deletexpath = "//a[contains(@class, 'editing_delete')]"; $this->execute("behat_general::i_click_on", array($slotxpath . $deletexpath, "xpath_element")); $this->execute('behat_general::i_click_on_in_the', array('Yes', "button", "Confirm", "dialogue") ); } /** * Set the section heading for a given section on the Edit quiz page * * @When /^I change quiz section heading "(?P<section_name_string>(?:[^"]|\\")*)" to "(?P<new_section_heading_string>(?:[^"]|\\")*)"$/ * @param string $sectionname the heading to change. * @param string $sectionheading the new heading to set. */ public function i_set_the_section_heading_for($sectionname, $sectionheading) { $this->execute('behat_general::click_link', $this->escape("Edit heading '{$sectionname}'")); $this->execute('behat_general::assert_page_contains_text', $this->escape(get_string('edittitleinstructions'))); $this->execute('behat_general::i_press_named_key', ['', 'backspace']); $this->execute('behat_general::i_type', [$sectionheading]); $this->execute('behat_general::i_press_named_key', ['', 'enter']); } /** * Check that a given question comes after a given section heading in the * quiz navigation block. * * @Then /^I should see question "(?P<questionnumber>\d+)" in section "(?P<section_heading_string>(?:[^"]|\\")*)" in the quiz navigation$/ * @param int $questionnumber the number of the question to check. * @param string $sectionheading which section heading it should appear after. */ public function i_should_see_question_in_section_in_the_quiz_navigation($questionnumber, $sectionheading) { // Using xpath literal to avoid quotes problems. $questionnumberliteral = behat_context_helper::escape('Question ' . $questionnumber); $headingliteral = behat_context_helper::escape($sectionheading); // Split in two checkings to give more feedback in case of exception. $exception = new ExpectationException('Question "' . $questionnumber . '" is not in section "' . $sectionheading . '" in the quiz navigation.', $this->getSession()); $xpath = "//*[@id = 'mod_quiz_navblock']//*[contains(concat(' ', normalize-space(@class), ' '), ' qnbutton ') and " . "contains(., {$questionnumberliteral}) and contains(preceding-sibling::h3[1], {$headingliteral})]"; $this->find('xpath', $xpath); } /** * Helper used by user_has_attempted_with_responses, * user_has_started_an_attempt_at_quiz_with_details, etc. * * @param TableNode $attemptinfo data table from the Behat step * @return array with two elements, $forcedrandomquestions, $forcedvariants, * that can be passed to $quizgenerator->create_attempt. */ protected function extract_forced_randomisation_from_attempt_info(TableNode $attemptinfo) { global $DB; $forcedrandomquestions = []; $forcedvariants = []; foreach ($attemptinfo->getHash() as $slotinfo) { if (empty($slotinfo['slot'])) { throw new ExpectationException('When simulating a quiz attempt, ' . 'the slot column is required.', $this->getSession()); } if (!empty($slotinfo['actualquestion'])) { $forcedrandomquestions[$slotinfo['slot']] = $DB->get_field('question', 'id', ['name' => $slotinfo['actualquestion']], MUST_EXIST); } if (!empty($slotinfo['variant'])) { $forcedvariants[$slotinfo['slot']] = (int) $slotinfo['variant']; } } return [$forcedrandomquestions, $forcedvariants]; } /** * Helper used by user_has_attempted_with_responses, user_has_checked_answers_in_their_attempt_at_quiz, * user_has_input_answers_in_their_attempt_at_quiz, etc. * * @param TableNode $attemptinfo data table from the Behat step * @return array of responses that can be passed to $quizgenerator->submit_responses. */ protected function extract_responses_from_attempt_info(TableNode $attemptinfo) { $responses = []; foreach ($attemptinfo->getHash() as $slotinfo) { if (empty($slotinfo['slot'])) { throw new ExpectationException('When simulating a quiz attempt, ' . 'the slot column is required.', $this->getSession()); } if (!array_key_exists('response', $slotinfo)) { throw new ExpectationException('When simulating a quiz attempt, ' . 'the response column is required.', $this->getSession()); } $responses[$slotinfo['slot']] = $slotinfo['response']; } return $responses; } /** * Attempt a quiz. * * The first row should be column names: * | slot | actualquestion | variant | response | * The first two of those are required. The others are optional. * * slot The slot * actualquestion This column is optional, and is only needed if the quiz contains * random questions. If so, this will let you control which actual * question gets picked when this slot is 'randomised' at the * start of the attempt. If you don't specify, then one will be picked * at random (which might make the response meaningless). * Give the question name. * variant This column is similar, and also options. It is only needed if * the question that ends up in this slot returns something greater * than 1 for $question->get_num_variants(). Like with actualquestion, * if you specify a value here it is used the fix the 'random' choice * made when the quiz is started. * response The response that was submitted. How this is interpreted depends on * the question type. It gets passed to * {@link core_question_generator::get_simulated_post_data_for_question_attempt()} * and therefore to the un_summarise_response method of the question to decode. * * Then there should be a number of rows of data, one for each question you want to add. * There is no need to supply answers to all questions. If so, other qusetions will be * left unanswered. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @param TableNode $attemptinfo information about the questions to add, as above. * @Given /^user "([^"]*)" has attempted "([^"]*)" with responses:$/ */ public function user_has_attempted_with_responses($username, $quizname, TableNode $attemptinfo) { global $DB; /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); list($forcedrandomquestions, $forcedvariants) = $this->extract_forced_randomisation_from_attempt_info($attemptinfo); $responses = $this->extract_responses_from_attempt_info($attemptinfo); $this->set_user($user); $attempt = $quizgenerator->create_attempt($quizid, $user->id, $forcedrandomquestions, $forcedvariants); $quizgenerator->submit_responses($attempt->id, $responses, false, true); $this->set_user(); } /** * Start a quiz attempt without answers. * * Then there should be a number of rows of data, one for each question you want to add. * There is no need to supply answers to all questions. If so, other qusetions will be * left unanswered. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*)"$/ */ public function user_has_started_an_attempt_at_quiz($username, $quizname) { global $DB; /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); $this->set_user($user); $quizgenerator->create_attempt($quizid, $user->id); $this->set_user(); } /** * Start a quiz attempt without answers. * * The supplied data table for have a row for each slot where you want * to force either which random question was chose, or which random variant * was used, as for {@link user_has_attempted_with_responses()} above. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @param TableNode $attemptinfo information about the questions to add, as above. * @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*)" randomised as follows:$/ */ public function user_has_started_an_attempt_at_quiz_with_details($username, $quizname, TableNode $attemptinfo) { global $DB; /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); list($forcedrandomquestions, $forcedvariants) = $this->extract_forced_randomisation_from_attempt_info($attemptinfo); $this->set_user($user); $quizgenerator->create_attempt($quizid, $user->id, $forcedrandomquestions, $forcedvariants); $this->set_user(); } /** * Input answers to particular questions an existing quiz attempt, without * simulating a click of the 'Check' button, if any. * * Then there should be a number of rows of data, with two columns slot and response, * as for {@link user_has_attempted_with_responses()} above. * There is no need to supply answers to all questions. If so, other questions will be * left unanswered. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @param TableNode $attemptinfo information about the questions to add, as above. * @throws \Behat\Mink\Exception\ExpectationException * @Given /^user "([^"]*)" has input answers in their attempt at quiz "([^"]*)":$/ */ public function user_has_input_answers_in_their_attempt_at_quiz($username, $quizname, TableNode $attemptinfo) { global $DB; /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); $responses = $this->extract_responses_from_attempt_info($attemptinfo); $this->set_user($user); $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true); $quizgenerator->submit_responses(key($attempts), $responses, false, false); $this->set_user(); } /** * Submit answers to questions an existing quiz attempt, with a simulated click on the 'Check' button. * * This step should only be used with question behaviours that have have * a 'Check' button. Those include Interactive with multiple tires, Immediate feedback * and Immediate feedback with CBM. * * Then there should be a number of rows of data, with two columns slot and response, * as for {@link user_has_attempted_with_responses()} above. * There is no need to supply answers to all questions. If so, other questions will be * left unanswered. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @param TableNode $attemptinfo information about the questions to add, as above. * @throws \Behat\Mink\Exception\ExpectationException * @Given /^user "([^"]*)" has checked answers in their attempt at quiz "([^"]*)":$/ */ public function user_has_checked_answers_in_their_attempt_at_quiz($username, $quizname, TableNode $attemptinfo) { global $DB; /** @var mod_quiz_generator $quizgenerator */ $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz'); $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); $responses = $this->extract_responses_from_attempt_info($attemptinfo); $this->set_user($user); $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true); $quizgenerator->submit_responses(key($attempts), $responses, true, false); $this->set_user(); } /** * Finish an existing quiz attempt. * * @param string $username the username of the user that will attempt. * @param string $quizname the name of the quiz the user will attempt. * @Given /^user "([^"]*)" has finished an attempt at quiz "([^"]*)"$/ */ public function user_has_finished_an_attempt_at_quiz($username, $quizname) { global $DB; $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST); $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST); $this->set_user($user); $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true); $attemptobj = quiz_attempt::create(key($attempts)); $attemptobj->process_finish(time(), true); $this->set_user(); } }