Merge branch 'MDL-35603-import-limit' of git://github.com/rlorenzo/moodle
authorDan Poltawski <dan@moodle.com>
Tue, 2 Apr 2013 06:25:35 +0000 (14:25 +0800)
committerDan Poltawski <dan@moodle.com>
Tue, 2 Apr 2013 06:25:35 +0000 (14:25 +0800)
72 files changed:
admin/tests/behat/behat_admin.php [new file with mode: 0644]
admin/tests/behat/display_short_names.feature [new file with mode: 0644]
admin/tests/behat/upload_users.feature [new file with mode: 0644]
admin/tool/behat/cli/init.php
admin/tool/behat/renderer.php
backup/util/ui/tests/behat/backup_courses.feature [new file with mode: 0644]
backup/util/ui/tests/behat/behat_backup.php [new file with mode: 0644]
backup/util/ui/tests/behat/duplicate_activities.feature [new file with mode: 0644]
backup/util/ui/tests/behat/restore_moodle2_courses.feature [new file with mode: 0644]
blocks/tests/behat/add_blocks.feature
blocks/tests/behat/behat_blocks.php
completion/tests/behat/behat_completion.php [new file with mode: 0644]
completion/tests/behat/enable_manual_complete_mark.feature [new file with mode: 0644]
completion/tests/behat/restrict_section_availability.feature [new file with mode: 0644]
course/lib.php
course/modlib.php
course/tests/behat/add_activities.feature
course/tests/behat/behat_course.php
course/tests/behat/force_group_mode.feature [new file with mode: 0644]
course/tests/behat/section_highlighting.feature [new file with mode: 0644]
course/tests/behat/section_visibility.feature [new file with mode: 0644]
course/tests/courselib_test.php
lib/behat/behat_base.php
lib/behat/behat_field_manager.php
lib/behat/form_field/behat_form_field.php
lib/behat/form_field/behat_form_modvisible.php [new file with mode: 0644]
lib/tests/behat/behat_forms.php
lib/tests/behat/behat_general.php
lib/tests/fixtures/upload_users.csv
mod/forum/tests/behat/add_forum.feature [new file with mode: 0644]
mod/forum/tests/behat/behat_mod_forum.php [new file with mode: 0644]
mod/forum/tests/behat/completion_condition_number_discussions.feature [new file with mode: 0644]
mod/forum/tests/behat/discussion_display.feature [new file with mode: 0644]
mod/forum/tests/behat/edit_post_student.feature [new file with mode: 0644]
mod/forum/tests/behat/edit_post_teacher.feature [new file with mode: 0644]
mod/forum/tests/behat/single_forum_discussion.feature [new file with mode: 0644]
mod/forum/tests/behat/track_read_posts.feature [new file with mode: 0644]
mod/quiz/attempt.php
mod/quiz/attemptlib.php
mod/quiz/autosave.ajax.php [new file with mode: 0644]
mod/quiz/lang/en/quiz.php
mod/quiz/settings.php
mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-coverage.js [new file with mode: 0644]
mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-debug.js [new file with mode: 0644]
mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-min.js [new file with mode: 0644]
mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave.js [new file with mode: 0644]
mod/quiz/yui/src/autosave/build.json [new file with mode: 0644]
mod/quiz/yui/src/autosave/js/autosave.js [new file with mode: 0644]
mod/quiz/yui/src/autosave/meta/autosave.json [new file with mode: 0644]
mod/survey/tests/behat/survey_types.feature [new file with mode: 0644]
question/behaviour/behaviourbase.php
question/engine/datalib.php
question/engine/lib.php
question/engine/questionattempt.php
question/engine/questionusage.php
question/engine/tests/helpers.php
question/engine/tests/questionattempt_db_test.php [new file with mode: 0644]
question/engine/tests/questionattempt_test.php
question/engine/tests/questionattempt_with_steps_test.php [new file with mode: 0644]
question/engine/tests/questionusage_autosave_test.php [new file with mode: 0644]
question/engine/tests/unitofwork_test.php
question/tests/generator/lib.php
question/type/match/tests/walkthrough_test.php
question/type/multianswer/tests/walkthrough_test.php
question/type/numerical/tests/walkthrough_test.php
question/type/shortanswer/tests/helper.php
question/type/upgrade.txt
repository/tests/behat/behat_filepicker.php [moved from lib/tests/behat/behat_filepicker.php with 99% similarity]
repository/tests/behat/create_folders.feature [new file with mode: 0644]
repository/tests/behat/zip_and_unzip.feature [moved from admin/tool/behat/tests/behat/manipulate_filepicker.feature with 66% similarity]
repository/upload/tests/behat/upload_file.feature
theme/mymobile/config.php

diff --git a/admin/tests/behat/behat_admin.php b/admin/tests/behat/behat_admin.php
new file mode 100644 (file)
index 0000000..cdca881
--- /dev/null
@@ -0,0 +1,97 @@
+<?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 with administration.
+ *
+ * @package   core
+ * @category  test
+ * @copyright 2013 David Monllaó
+ * @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__ . '/../../../lib/behat/behat_field_manager.php');
+
+use Behat\Gherkin\Node\TableNode as TableNode,
+    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
+
+/**
+ * Site administration level steps definitions.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2013 David Monllaó
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_admin extends behat_base {
+
+    /**
+     * Sets the specified site settings. A table with | Setting label | value | is expected.
+     *
+     * @Given /^I set the following administration settings values:$/
+     * @param TableNode $table
+     */
+    public function i_set_the_following_administration_settings_values(TableNode $table) {
+
+        if (!$data = $table->getRowsHash()) {
+            return;
+        }
+
+        foreach ($data as $label => $value) {
+
+            // We expect admin block to be visible, otherwise go to homepage.
+            if (!$this->getSession()->getPage()->find('css', '.block_settings')) {
+                $this->getSession()->visit($this->locate_path('/'));
+                $this->getSession()->wait(self::TIMEOUT, '(document.readyState === "complete")');
+            }
+
+            // Search by label.
+            $searchbox = $this->find_field('Search in settings');
+            $searchbox->setValue($label);
+            $submitsearch = $this->find('css', 'form.adminsearchform input[type=submit]');
+            $submitsearch->press();
+
+            $this->getSession()->wait(self::TIMEOUT, '(document.readyState === "complete")');
+
+            // Admin settings does not use the same DOM structure than other moodle forms
+            // but we also need to use lib/behat/form_field/* to deal with the different moodle form elements.
+            $exception = new ElementNotFoundException($this->getSession(), '"' . $label . '" administration setting ');
+            $fieldxpath = "//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]
+[@id=//label[contains(normalize-space(string(.)), '" . $label . "')]/@for]";
+            $fieldnode = $this->find('xpath', $fieldxpath, $exception);
+            $formfieldtypenode = $this->find('xpath', $fieldxpath . "/ancestor::div[@class='form-setting']
+/child::div[contains(concat(' ', @class, ' '),  ' form-')]/child::*/parent::div");
+
+            // Getting the class which contains the field type.
+            $classes = explode(' ', $formfieldtypenode->getAttribute('class'));
+            foreach ($classes as $class) {
+                if (substr($class, 0, 5) == 'form-') {
+                    $type = substr($class, 5);
+                }
+            }
+
+            // Instantiating the appropiate field type.
+            $field = behat_field_manager::get_field_instance($type, $fieldnode, $this->getSession());
+            $field->set_value($value);
+
+            $this->find_button('Save changes')->press();
+        }
+    }
+
+}
diff --git a/admin/tests/behat/display_short_names.feature b/admin/tests/behat/display_short_names.feature
new file mode 100644 (file)
index 0000000..836d753
--- /dev/null
@@ -0,0 +1,22 @@
+@admin
+Feature: Display extended course names
+  In order to display more info about the courses
+  As a moodle admin
+  I need to display courses short names along with courses full names
+
+  Background:
+    Given the following "courses" exists:
+      | fullname | shortname | category |
+      | Course fullname | C_shortname | 0 |
+    And I log in as "admin"
+
+  Scenario: Courses list without extended course names (default value)
+    Then I should see "Course fullname"
+    And I should not see "C_shortname Course fullname"
+
+  Scenario: Courses list with extended course names
+    Given I click on "Courses" "link" in the "//div[@id='settingsnav']//descendant::li[contains(concat(' ', @class, ' '), ' type_setting ')][not(contains(., 'Site administration'))][contains(., 'Appearance')]" "xpath_element"
+    And I check "Display extended course names"
+    When I press "Save changes"
+    And I am on homepage
+    Then I should see "C_shortname Course fullname"
diff --git a/admin/tests/behat/upload_users.feature b/admin/tests/behat/upload_users.feature
new file mode 100644 (file)
index 0000000..e61208e
--- /dev/null
@@ -0,0 +1,46 @@
+@admin @_only_local
+Feature: Upload users
+  In order to add users to the system
+  As a moodle admin
+  I need to upload files containing the users data
+
+  @javascript
+  Scenario: Upload users enrolling them on courses and groups
+    Given the following "courses" exists:
+      | fullname | shortname | category |
+      | Maths | math102 | 0 |
+    And the following "groups" exists:
+      | name | course | idnumber |
+      | Section 1 | math102 | S1 |
+      | Section 3 | math102 | S3 |
+    And I log in as "admin"
+    And I expand "Front page settings" node
+    And I expand "Site administration" node
+    And I expand "Users" node
+    And I expand "Accounts" node
+    And I follow "Upload users"
+    When I upload "lib/tests/fixtures/upload_users.csv" file to "File" filepicker
+    And I press "Upload users"
+    Then I should see "Upload users preview"
+    And I should see "Tom"
+    And I should see "Jones"
+    And I should see "verysecret"
+    And I should see "jonest@someplace.edu"
+    And I should see "Reznor"
+    And I should see "course1"
+    And I should see "math102"
+    And I should see "group1"
+    And I should see "Section 1"
+    And I press "Upload users"
+    And I press "Continue"
+    And I follow "Browse list of users"
+    And I should see "Tom Jones"
+    And I should see "Trent Reznor"
+    And I should see "reznor@someplace.edu"
+    And I am on homepage
+    And I follow "Maths"
+    And I expand "Users" node
+    And I follow "Groups"
+    And I select "Section 1 (1)" from "groups"
+    And I wait "4" seconds
+    And the "members" select box should contain "Tom Jones"
index 1916367..ca68c16 100644 (file)
@@ -65,7 +65,7 @@ if ($code == 0) {
     // Changing to moodle dirroot to run composer related commands at project level.
     chdir(__DIR__ . '/../../../..');
     if (!file_exists(__DIR__ . '/../../../../composer.phar')) {
-        passthru("curl http://getcomposer.org/install | php", $code);
+        passthru("curl http://getcomposer.org/installer | php", $code);
         if ($code != 0) {
             exit($code);
         }
index 0df7e3a..503bc09 100644 (file)
@@ -89,7 +89,7 @@ class tool_behat_renderer extends plugin_renderer_base {
             $stepsdefinitions = implode('', $stepsdefinitions);
 
             // Replace text selector type arguments with a user-friendly select.
-            $stepsdefinitions = preg_replace_callback('/(TEXT_SELECTOR_STRING)/',
+            $stepsdefinitions = preg_replace_callback('/(TEXT_SELECTOR\d?_STRING)/',
                 function ($matches) {
                     return html_writer::select(behat_command::$allowedtextselectors, uniqid());
                 },
@@ -97,7 +97,7 @@ class tool_behat_renderer extends plugin_renderer_base {
             );
 
             // Replace selector type arguments with a user-friendly select.
-            $stepsdefinitions = preg_replace_callback('/(SELECTOR_STRING)/',
+            $stepsdefinitions = preg_replace_callback('/(SELECTOR\d?_STRING)/',
                 function ($matches) {
                     return html_writer::select(behat_command::$allowedselectors, uniqid());
                 },
diff --git a/backup/util/ui/tests/behat/backup_courses.feature b/backup/util/ui/tests/behat/backup_courses.feature
new file mode 100644 (file)
index 0000000..584354e
--- /dev/null
@@ -0,0 +1,37 @@
+@backup
+Feature: Backup Moodle courses
+  In order to save and store course contents
+  As a moodle admin
+  I need to create backups of courses
+
+  Background:
+    Given the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And I log in as "admin"
+
+  @javascript
+  Scenario: Backup a course providing options
+    When I backup "Course 1" course using this options:
+    Then I should see "Restore"
+    And I click on "Restore" "link" in the ".backup-files-table" "css_element"
+    And I should see "URL of backup"
+    And I should see "Anonymize user information"
+
+  @javascript
+  Scenario: Backup a course with default options
+    When I backup "Course 1" course using this options:
+      | Filename | test_backup.mbz |
+      | Include calendar events | 0 |
+      | Include course logs | 1 |
+      | setting_section_section_5_userinfo | 0 |
+      | setting_section_section_5_included | 0 |
+    Then I should see "Restore"
+    And I click on "Restore" "link" in the ".backup-files-table" "css_element"
+    And I should not see "Section 3"
+    And I press "Continue"
+    And I click on "Continue" "button" in the ".bcs-current-course" "css_element"
+    And I click on "//div[contains(concat(' ', @class, ' '), ' fitem ')][contains(., 'Include calendar events')]/descendant::img" "xpath_element"
+    And I click on "setting_root_logs" "checkbox" in the "//div[contains(@class, 'fitem')][contains(., 'Include course logs')]" "xpath_element"
+    And I press "Cancel"
+    And I click on "Cancel" "button" in the ".confirmation-dialogue" "css_element"
diff --git a/backup/util/ui/tests/behat/behat_backup.php b/backup/util/ui/tests/behat/behat_backup.php
new file mode 100644 (file)
index 0000000..8f02603
--- /dev/null
@@ -0,0 +1,288 @@
+<?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/>.
+
+/**
+ * Backup and restore actions to help behat feature files writting.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2013 David Monllaó
+ * @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__ . '/../../../../../lib/behat/behat_field_manager.php');
+
+use Behat\Gherkin\Node\TableNode as TableNode,
+    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException,
+    Behat\Mink\Exception\ExpectationException as ExpectationException;
+
+/**
+ * Backup-related steps definitions.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2013 David Monllaó
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_backup extends behat_base {
+
+    /**
+     * Backups the specified course using the provided options. If you are interested in restoring this backup would be useful to provide a 'Filename' option.
+     *
+     * @Given /^I backup "(?P<course_fullname_string>(?:[^"]|\\")*)" course using this options:$/
+     * @param string $backupcourse
+     * @param TableNode $options Backup options or false if no options provided
+     */
+    public function i_backup_course_using_this_options($backupcourse, $options = false) {
+
+        // We can not use other steps here as we don't know where the provided data
+        // table elements are used, and we need to catch exceptions contantly.
+
+        // Go to homepage.
+        $this->getSession()->visit($this->locate_path('/'));
+
+        // Click the course link.
+        $this->find_link($backupcourse)->click();
+
+        // Click the backup link.
+        $this->find_link('Backup')->click();
+
+        // Initial settings.
+        $this->fill_backup_restore_form($options);
+        $this->find_button('Next')->press();
+
+        // Schema settings.
+        $this->fill_backup_restore_form($options);
+        $this->find_button('Next')->press();
+
+        // Confirmation and review, backup filename can also be specified.
+        $this->fill_backup_restore_form($options);
+        $this->find_button('Perform backup')->press();
+
+        // Waiting for it to finish.
+        $this->wait(10);
+
+        // Last backup continue button.
+        $this->find_button('Continue')->press();
+    }
+
+    /**
+     * Restores the backup into the specified course and the provided options. You should be in the 'Restore' page where the backup is.
+     *
+     * @Given /^I restore "(?P<backup_filename_string>(?:[^"]|\\")*)" backup into "(?P<existing_course_fullname_string>(?:[^"]|\\")*)" course using this options:$/
+     * @param string $backupfilename
+     * @param string $existingcourse
+     * @param TableNode $options Restore forms options or false if no options provided
+     */
+    public function i_restore_backup_into_course_using_this_options($backupfilename, $existingcourse, $options = false) {
+
+        // Confirm restore.
+        $this->select_backup($backupfilename);
+
+        // Selecting the specified course (we can not call behat_forms::select_radio here as is in another behat subcontext).
+        $existingcourse = str_replace("'", "\'", $existingcourse);
+        $radionode = $this->find('xpath', "//div[contains(@class, 'bcs-existing-course')]
+/descendant::div[@class='restore-course-search']
+/descendant::tr[contains(., '" . $existingcourse . "')]
+/descendant::input[@type='radio']");
+        $radionode->check();
+        $radionode->click();
+
+        // Pressing the continue button of the restore into an existing course section.
+        $continuenode = $this->find('xpath', "//div[contains(@class, 'bcs-existing-course')]/descendant::input[@type='submit'][@value='Continue']");
+        $continuenode->click();
+        $this->wait();
+
+        // Common restore process using provided key/value options.
+        $this->process_restore($options);
+    }
+
+    /**
+     * Restores the specified backup into a new course using the provided options. You should be in the 'Restore' page where the backup is.
+     *
+     * @Given /^I restore "(?P<backup_filename_string>(?:[^"]|\\")*)" backup into a new course using this options:$/
+     * @param string $backupfilename
+     * @param TableNode $options Restore forms options or false if no options provided
+     */
+    public function i_restore_backup_into_a_new_course_using_this_options($backupfilename, $options = false) {
+
+        // Confirm restore.
+        $this->select_backup($backupfilename);
+
+        // The first category in the list.
+        $radionode = $this->find('xpath', "//div[contains(@class, 'bcs-new-course')]
+/descendant::div[@class='restore-course-search']
+/descendant::input[@type='radio']");
+        $radionode->check();
+        $radionode->click();
+
+        // Pressing the continue button of the restore into an existing course section.
+        $continuenode = $this->find('xpath', "//div[contains(@class, 'bcs-new-course')]/descendant::input[@type='submit'][@value='Continue']");
+        $continuenode->click();
+        $this->wait();
+
+        // Common restore process using provided key/value options.
+        $this->process_restore($options);
+    }
+
+    /**
+     * Merges the backup into the current course using the provided restore options. You should be in the 'Restore' page where the backup is.
+     *
+     * @Given /^I merge "(?P<backup_filename_string>(?:[^"]|\\")*)" backup into the current course using this options:$/
+     * @param string $backupfilename
+     * @param TableNode $options Restore forms options or false if no options provided
+     */
+    public function i_merge_backup_into_the_current_course($backupfilename, $options = false) {
+
+        // Confirm restore.
+        $this->select_backup($backupfilename);
+
+        // Merge without deleting radio option.
+        $radionode = $this->find('xpath', "//div[contains(@class, 'bcs-current-course')]
+/descendant::input[@type='radio'][@name='target'][@value='1']");
+        $radionode->check();
+        $radionode->click();
+
+        // Pressing the continue button of the restore merging section.
+        $continuenode = $this->find('xpath', "//div[contains(@class, 'bcs-current-course')]/descendant::input[@type='submit'][@value='Continue']");
+        $continuenode->click();
+        $this->wait();
+
+        // Common restore process using provided key/value options.
+        $this->process_restore($options);
+    }
+
+    /**
+     * Merges the backup into the current course after deleting this contents, using the provided restore options. You should be in the 'Restore' page where the backup is.
+     *
+     * @Given /^I merge "(?P<backup_filename_string>(?:[^"]|\\")*)" backup into the current course after deleting it's contents using this options:$/
+     * @param string $backupfilename
+     * @param TableNode $options Restore forms options or false if no options provided
+     */
+    public function i_merge_backup_into_current_course_deleting_its_contents($backupfilename, $options = false) {
+
+        // Confirm restore.
+        $this->select_backup($backupfilename);
+
+        // Delete contents radio option.
+        $radionode = $this->find('xpath', "//div[contains(@class, 'bcs-current-course')]
+/descendant::input[@type='radio'][@name='target'][@value='0']");
+        $radionode->check();
+        $radionode->click();
+
+        // Pressing the continue button of the restore merging section.
+        $continuenode = $this->find('xpath', "//div[contains(@class, 'bcs-current-course')]/descendant::input[@type='submit'][@value='Continue']");
+        $continuenode->click();
+        $this->wait();
+
+        // Common restore process using provided key/value options.
+        $this->process_restore($options);
+    }
+
+    /**
+     * Selects the backup to restore.
+     *
+     * @throws ExpectationException
+     * @param string $backupfilename
+     * @return void
+     */
+    protected function select_backup($backupfilename) {
+
+        // Using xpath as there are other restore links before this one.
+        $exception = new ExpectationException('The "' . $backupfilename . '" backup file can not be found in this page', $this->getSession());
+        $xpath = "//tr[contains(., '" . $backupfilename . "')]/descendant::a[contains(., 'Restore')]";
+        $restorelink = $this->find('xpath', $xpath, $exception);
+        $restorelink->click();
+
+        // Confirm the backup contents.
+        $restore = $this->find_button('Continue')->press();
+    }
+
+    /**
+     * Executes the common steps of all restore processes.
+     *
+     * @param TableNode $options The backup and restore options or false if no options provided
+     * @return void
+     */
+    protected function process_restore($options) {
+
+        // Settings.
+        $this->fill_backup_restore_form($options);
+        $this->find_button('Next')->press();
+
+        // Schema.
+        $this->fill_backup_restore_form($options);
+        $this->find_button('Next')->press();
+
+        // Review, no options here.
+        $this->find_button('Perform restore')->press();
+        $this->wait(10);
+
+        // Last restore continue button, redirected to restore course after this.
+        $this->find_button('Continue')->press();
+    }
+
+    /**
+     * Tries to fill the current page form elements with the provided options.
+     *
+     * This step is slow as it spins over each provided option, we are
+     * not expected to have lots of provided options, anyways, is better
+     * to be conservative and wait for the elements to appear rather than
+     * to have false failures.
+     *
+     * @param TableNode $options The backup and restore options or false if no options provided
+     * @return void
+     */
+    protected function fill_backup_restore_form($options) {
+
+        // Nothing to fill if no options are provided.
+        if (!$options) {
+            return;
+        }
+
+        // If we find any of the provided options in the current form we should set the value.
+        $datahash = $options->getRowsHash();
+        foreach ($datahash as $locator => $value) {
+
+            try {
+                $fieldnode = $this->find_field($locator);
+                $field = behat_field_manager::get_field($fieldnode, $locator, $this->getSession());
+                $field->set_value($value);
+
+            } catch (ElementNotFoundException $e) {
+                // Next provided option then, this one should be part of another page's fields.
+            }
+        }
+    }
+
+    /**
+     * Waits until the DOM is ready.
+     *
+     * @param int To override the default timeout
+     * @return void
+     */
+    protected function wait($timeout = false) {
+
+        if (!$timeout) {
+            $timeout = self::TIMEOUT;
+        }
+        $this->getSession()->wait($timeout, '(document.readyState === "complete")');
+    }
+
+}
diff --git a/backup/util/ui/tests/behat/duplicate_activities.feature b/backup/util/ui/tests/behat/duplicate_activities.feature
new file mode 100644 (file)
index 0000000..f23efc4
--- /dev/null
@@ -0,0 +1,33 @@
+@backup
+Feature: Duplicate activities
+  In order to set up my course contents quickly
+  As a moodle teacher
+  I need to duplicate activities inside the same course
+
+  @javascript
+  Scenario: Duplicate an activity
+    Given the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "Database" to section "1" and I fill the form with:
+      | Name | Test database name |
+      | Description | Test database description |
+    When I click on "Duplicate" "link" in the "#section-1" "css_element"
+    And I press "Continue"
+    And I press "Edit the new copy"
+    And I fill the moodle form with:
+      | Name | Duplicated database name |
+      | Description | Duplicated database description |
+    And I press "Save and return to course"
+    Then I should see "Test database name" in the "#section-1" "css_element"
+    And I should see "Duplicated database name" in the "#section-1" "css_element"
+    And "Test database name" "link" should appear before "Duplicated database name" "link"
diff --git a/backup/util/ui/tests/behat/restore_moodle2_courses.feature b/backup/util/ui/tests/behat/restore_moodle2_courses.feature
new file mode 100644 (file)
index 0000000..2c4a57e
--- /dev/null
@@ -0,0 +1,100 @@
+@backup
+Feature: Restore Moodle 2 course backups
+  In order to continue using my stored course contents
+  As a moodle teacher and as a moodle admin
+  I need to restore them inside other Moodle courses or in new courses
+
+  Background:
+    Given the following "courses" exists:
+      | fullname | shortname | category | format |
+      | Course 1 | C1 | 0 | topics |
+      | Course 2 | C2 | 0 | topics |
+    And I log in as "admin"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Description | Test forum description |
+    And I add the "Community finder" block
+
+  @javascript
+  Scenario: Restore a course in another existing course
+    When I backup "Course 1" course using this options:
+      | Filename | test_backup.mbz |
+    And I restore "test_backup.mbz" backup into "Course 2" course using this options:
+    Then I should see "Course 2"
+    And I should see "Community finder"
+    And I should see "Test forum name"
+
+  @javascript
+  Scenario: Restore a course in a new course
+    When I backup "Course 1" course using this options:
+      | Filename | test_backup.mbz |
+    And I restore "test_backup.mbz" backup into a new course using this options:
+      | Course name | Course 1 restored in a new course |
+    Then I should see "Course 1 restored in a new course"
+    And I should see "Community finder"
+    And I should see "Test forum name"
+
+  @javascript
+  Scenario: Restore a backup into the same course
+    When I backup "Course 1" course using this options:
+      | Filename | test_backup.mbz |
+    And I merge "test_backup.mbz" backup into the current course using this options:
+      | setting_section_section_5_included | 0 |
+      | setting_section_section_5_userinfo | 0 |
+    Then I should see "Course 1"
+    And I should not see "Section 3"
+    And I should see "Community finder"
+    And I should see "Test forum name"
+
+  @javascript
+  Scenario: Restore a backup into the same course removing it's contents before that
+    When I backup "Course 1" course using this options:
+      | Filename | test_backup.mbz |
+    And I follow "Course 1"
+    And I add a "Forum" to section "1" and I fill the form with:
+      | Forum name | Test forum post backup name |
+      | Description | Test forum post backup description |
+    And I follow "Restore"
+    And I merge "test_backup.mbz" backup into the current course after deleting it's contents using this options:
+      | setting_section_section_5_userinfo | 0 |
+      | setting_section_section_5_included | 0 |
+    Then I should see "Course 1"
+    And I should not see "Section 3"
+    And I should not see "Test forum post backup name"
+    And I should see "Community finder"
+    And I should see "Test forum name"
+
+  @javascript
+  Scenario: Restore a backup into a new course changing the course format afterwards
+    Given I backup "Course 1" course using this options:
+      | Filename | test_backup.mbz |
+    When I restore "test_backup.mbz" backup into a new course using this options:
+    Then I should see "Topic 1"
+    And I should see "Test forum name"
+    And I follow "Edit settings"
+    And the "id_format" field should match "Topics format" value
+    And I fill the moodle form with:
+      | id_startdate_day | 1 |
+      | id_startdate_month | January |
+      | id_startdate_year | 2020 |
+      | id_format | Weekly format |
+    And I press "Save changes"
+    And I should see "1 January - 7 January"
+    And I should see "Test forum name"
+    And I follow "Edit settings"
+    And the "id_format" field should match "Weekly format" value
+    And I fill the moodle form with:
+      | id_format | Social format |
+    And I press "Save changes"
+    And I should see "An open forum for chatting about anything you want to"
+    And I follow "Edit settings"
+    And the "id_format" field should match "Social format" value
+    And I fill the moodle form with:
+      | id_format | SCORM format |
+    And I press "Save changes"
+    And I should see "Adding a new SCORM package"
+    And I follow "Edit settings"
+    And the "id_format" field should match "SCORM format" value
+    And I press "Cancel"
index 7ee5644..47caaaa 100644 (file)
@@ -4,8 +4,7 @@ Feature: Add blocks
   As a teacher
   I need to add blocks to pages
 
-  @javascript
-  Scenario: Add a block to a course
+  Background:
     Given the following "users" exists:
       | username | firstname | lastname | email |
       | student1 | Student | 1 | student1@asd.com |
@@ -21,4 +20,9 @@ Feature: Add blocks
     And I follow "Course 1"
     And I turn editing mode on
     When I add the "Blog menu" block
-    Then I should see "View all of my entries"
+    Then I should see "View my entries about this course"
+
+  @javascript
+  Scenario: Add a block to a course with Javascript enabled
+
+  Scenario: Add a block to a course with Javascript disabled
index c90f1df..1c8f70d 100644 (file)
@@ -46,7 +46,16 @@ class behat_blocks extends behat_base {
      * @param string $blockname
      */
     public function i_add_the_block($blockname) {
-        return new Given('I select "' . $blockname . '" from "bui_addblock"');
+        $steps = new Given('I select "' . $blockname . '" from "bui_addblock"');
+
+        // If we are running without javascript we need to submit the form.
+        if (!$this->running_javascript()) {
+            $steps = array(
+                $steps,
+                new Given('I click on "Go" "button" in the "#add_block" "css_element"')
+            );
+        }
+        return $steps;
     }
 
 }
diff --git a/completion/tests/behat/behat_completion.php b/completion/tests/behat/behat_completion.php
new file mode 100644 (file)
index 0000000..7bf57fb
--- /dev/null
@@ -0,0 +1,100 @@
+<?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/>.
+
+/**
+ * Completion steps definitions.
+ *
+ * @package    core_completion
+ * @category   test
+ * @copyright  2013 David Monllaó
+ * @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');
+
+use Behat\Behat\Context\Step\Given as Given,
+    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
+
+/**
+ * Steps definitions to deal with course and activities completion.
+ *
+ * @package    core_completion
+ * @category   test
+ * @copyright  2013 David Monllaó
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_completion extends behat_base {
+
+    /**
+     * Checks that the specified user has completed the specified activity of the current course.
+     *
+     * @Then /^"(?P<user_fullname_string>(?:[^"]|\\")*)" user has completed "(?P<activity_name_string>(?:[^"]|\\")*)" activity$/
+     * @param string $userfullname
+     * @param string $activityname
+     */
+    public function user_has_completed_activity($userfullname, $activityname) {
+
+        // Will throw an exception if the element can not be hovered.
+        $xpath = "//table[@id='completion-progress']/descendant::img[contains(@title, '" . $userfullname . ", " . $activityname . ": Completed')]";
+
+        return array(
+            new Given('I go to the current course activity completion report'),
+            new Given('I hover "' . $xpath . '" "xpath_element"')
+        );
+    }
+
+    /**
+     * Checks that the specified user has not completed the specified activity of the current course.
+     *
+     * @Then /^"(?P<user_fullname_string>(?:[^"]|\\")*)" user has not completed "(?P<activity_name_string>(?:[^"]|\\")*)" activity$/
+     * @param string $userfullname
+     * @param string $activityname
+     */
+    public function user_has_not_completed_activity($userfullname, $activityname) {
+
+        $xpath = "//table[@id='completion-progress']/descendant::img[contains(@title, '" . $userfullname . ", " . $activityname . ": Not completed')]";
+        return array(
+            new Given('I go to the current course activity completion report'),
+            new Given('I hover "' . $xpath . '" "xpath_element"')
+        );
+
+        return $steps;
+    }
+
+    /**
+     * Goes to the current course activity completion report.
+     *
+     * @Given /^I go to the current course activity completion report$/
+     */
+    public function go_to_the_current_course_activity_completion_report() {
+
+        $steps = array();
+
+        // Expand reports node if we can't see the link.
+        try {
+            $this->find('xpath', "//*[@id='settingsnav']/descendant::li/descendant::li[not(contains(@class,'collapsed'))]/descendant::p[contains(., 'Activity completion')]");
+        } catch (ElementNotFoundException $e) {
+            $steps[] = new Given('I expand "Reports" node');
+        }
+
+        $steps[] = new Given('I follow "Activity completion"');
+
+        return $steps;
+    }
+
+}
diff --git a/completion/tests/behat/enable_manual_complete_mark.feature b/completion/tests/behat/enable_manual_complete_mark.feature
new file mode 100644 (file)
index 0000000..0afd58f
--- /dev/null
@@ -0,0 +1,47 @@
+@core_completion
+Feature: Allow students to manually mark an activity as complete
+  In order to let students decide when an activity is completed
+  As a moodle teacher
+  I need to allow students to mark activities as completed
+
+  @javascript
+  Scenario: Mark an activity as completed
+    Given the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | Frist | teacher1@asd.com |
+      | student1 | Student | First | student1@asd.com |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "admin"
+    And I set the following administration settings values:
+      | Enable completion tracking | 1 |
+      | Enable conditional access | 1 |
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I follow "Edit settings"
+    And I fill the moodle form with:
+      | Completion tracking | Enabled, control via completion and activity settings |
+      | Completion tracking begins on enrolment | 1 |
+    And I press "Save changes"
+    When I add a "Forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Description | Test forum description |
+    Then "Student First" user has not completed "Test forum name" activity
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I press "Mark as complete: Test forum name"
+    And I wait "3" seconds
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I expand "Reports" node
+    And I follow "Activity completion"
+    And "Student First" user has completed "Test forum name" activity
diff --git a/completion/tests/behat/restrict_section_availability.feature b/completion/tests/behat/restrict_section_availability.feature
new file mode 100644 (file)
index 0000000..88866b1
--- /dev/null
@@ -0,0 +1,53 @@
+@core_completion
+Feature: Restrict sections availability through completion conditions
+  In order to control section's contents access through activities completion
+  As a moodle teacher
+  I need to restrict sections availability using different conditions
+
+  @javascript
+  Scenario: Show section greyed-out to students when completion conditions are not satisfied
+    Given the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | Frist | teacher1@asd.com |
+      | student1 | Student | First | student1@asd.com |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "admin"
+    And I set the following administration settings values:
+      | Enable completion tracking | 1 |
+      | Enable conditional access | 1 |
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I follow "Edit settings"
+    And I fill the moodle form with:
+      | Completion tracking | Enabled, control via completion and activity settings |
+      | Completion tracking begins on enrolment | 1 |
+    And I press "Save changes"
+    And I add a "Label" to section "1" and I fill the form with:
+      | Label text | Test label |
+      | Completion tracking | Students can manually mark the activity as completed |
+    And I add a "Page" to section "2" and I fill the form with:
+      | Name | Test page name |
+      | Description | Test page description |
+      | Page content | Test page contents |
+    When I click on "Edit summary" "link" in the "#section-2" "css_element"
+    And I fill the moodle form with:
+      | id_conditioncompletiongroup_0_conditionsourcecmid | Test label |
+      | id_conditioncompletiongroup_0_conditionrequiredcompletion | must be marked complete |
+      | Before section can be accessed | Show section greyed-out, with restriction information |
+    And I press "Save changes"
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    Then I should see "Not available until the activity Test label is marked complete."
+    And I should not see "Test page name"
+    And I press "Not completed: Test label. Select to mark as complete."
+    And I should see "Test page name"
+    And I should not see "Not available until the activity Test label is marked complete."
index f906949..9f76d76 100644 (file)
@@ -2340,17 +2340,19 @@ function moveto_module($mod, $section, $beforemod=NULL) {
     }
 
     // if moving to a hidden section then hide module
-    if (!$section->visible && $mod->visible) {
-        // Set this in the object because it is sent as a response to ajax calls.
-        $mod->visible = 0;
-        set_coursemodule_visible($mod->id, 0);
-        // Set visibleold to 1 so module will be visible when section is made visible.
-        $DB->set_field('course_modules', 'visibleold', 1, array('id' => $mod->id));
-    }
-    if ($section->visible && !$mod->visible) {
-        set_coursemodule_visible($mod->id, $mod->visibleold);
-        // Set this in the object because it is sent as a response to ajax calls.
-        $mod->visible = $mod->visibleold;
+    if ($mod->section != $section->id) {
+        if (!$section->visible && $mod->visible) {
+            // Set this in the object because it is sent as a response to ajax calls.
+            $mod->visible = 0;
+            set_coursemodule_visible($mod->id, 0);
+            // Set visibleold to 1 so module will be visible when section is made visible.
+            $DB->set_field('course_modules', 'visibleold', 1, array('id' => $mod->id));
+        }
+        if ($section->visible && !$mod->visible) {
+            set_coursemodule_visible($mod->id, $mod->visibleold);
+            // Set this in the object because it is sent as a response to ajax calls.
+            $mod->visible = $mod->visibleold;
+        }
     }
 
 /// Add the module into the new section
index 166c638..614ac8a 100644 (file)
@@ -61,6 +61,7 @@ function add_moduleinfo($moduleinfo, $course, $mform = null) {
     $newcm->module           = $moduleinfo->module;
     $newcm->instance         = 0; // Not known yet, will be updated later (this is similar to restore code).
     $newcm->visible          = $moduleinfo->visible;
+    $newcm->visibleold       = $moduleinfo->visible;
     $newcm->groupmode        = $moduleinfo->groupmode;
     $newcm->groupingid       = $moduleinfo->groupingid;
     $newcm->groupmembersonly = $moduleinfo->groupmembersonly;
@@ -128,7 +129,6 @@ function add_moduleinfo($moduleinfo, $course, $mform = null) {
     // Make sure visibility is set correctly (in particular in calendar).
     // Note: allow them to set it even without moodle/course:activityvisibility.
     set_coursemodule_visible($moduleinfo->coursemodule, $moduleinfo->visible);
-    $DB->set_field('course_modules', 'visibleold', 1, array('id' => $moduleinfo->coursemodule));
 
     if (isset($moduleinfo->cmidnumber)) { // Label.
         // Set cm idnumber - uniqueness is already verified by form validation.
index 1c77c44..932bca0 100644 (file)
@@ -4,8 +4,7 @@ Feature: Add activities to courses
   As a teacher
   I need to add activites to a course
 
-  @javascript
-  Scenario: Add an activity to the course
+  Background:
     Given the following "users" exists:
       | username | firstname | lastname | email |
       | student1 | Student | 1 | student1@asd.com |
@@ -19,8 +18,11 @@ Feature: Add activities to courses
       | student2 | C1 | student |
     And I log in as "admin"
     And I follow "Course 1"
-    When I turn editing mode on
-    And I add a "Database" to section "3" and I fill the form with:
+    And I turn editing mode on
+
+  @javascript
+  Scenario: Add an activity to a course
+    When I add a "Database" to section "3" and I fill the form with:
       | Name | Test name |
       | Description | Test database description |
       | Required entries | 9 |
@@ -35,21 +37,29 @@ Feature: Add activities to courses
 
   @javascript
   Scenario: Add an activity without the required fields
-    Given the following "users" exists:
-      | username | firstname | lastname | email |
-      | student1 | Student | 1 | student1@asd.com |
-      | student2 | Student | 2 | student2@asd.com |
-    And the following "courses" exists:
-      | fullname | shortname | format |
-      | Course 1 | C1 | topics |
-    And the following "course enrolments" exists:
-      | user | course | role |
-      | student1 | C1 | student |
-      | student2 | C1 | student |
-    And I log in as "admin"
-    And I follow "Course 1"
-    When I turn editing mode on
-    And I add a "Database" to section "3" and I fill the form with:
+    When I add a "Database" to section "3" and I fill the form with:
       | Name | Test name |
     Then I should see "Adding a new"
     And I should see "Required"
+
+  Scenario: Add an activity to a course with Javascript disabled
+    Then I should see "Add a resource to section 'Topic 1'"
+    And I should see "Add an activity to section 'Topic 1'"
+    And I should see "Add a resource to section 'Topic 2'"
+    And I should see "Add an activity to section 'Topic 2'"
+    And I should see "Add a resource to section 'Topic 3'"
+    And I should see "Add an activity to section 'Topic 3'"
+    And I add a "Label" to section "2"
+    And I should see "Adding a new Label to Topic 2"
+    And I fill the moodle form with:
+      | Label text | I'm a label |
+    And I press "Save and return to course"
+    And I add a "Database" to section "3"
+    And I should see "Adding a new Database to Topic 3"
+    And I fill the moodle form with:
+      | Name | Test database name |
+      | Description | Test database description |
+    And I press "Save and return to course"
+    And I should not see "Adding a new"
+    And I should see "Test database name"
+    And I should see "I'm a label"
index 0f1e0ca..c0622e2 100644 (file)
@@ -28,7 +28,9 @@
 require_once(__DIR__ . '/../../../lib/behat/behat_base.php');
 
 use Behat\Behat\Context\Step\Given as Given,
-    Behat\Gherkin\Node\TableNode as TableNode;
+    Behat\Gherkin\Node\TableNode as TableNode,
+    Behat\Mink\Exception\ExpectationException as ExpectationException,
+    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
 
 /**
  * Course-related steps definitions.
@@ -83,15 +85,331 @@ class behat_course extends behat_base {
      */
     public function i_add_to_section($activity, $section) {
 
-        // Clicks add activity or resource section link.
-        $sectionxpath = "//*[@id='section-" . $section . "']/*/*/*/div[@class='section-modchooser']/span/a";
-        $sectionnode = $this->find('xpath', $sectionxpath);
-        $sectionnode->click();
+        $sectionxpath = "//*[@id='section-" . $section . "']";
 
-        // Clicks the selected activity if it exists.
-        $activityxpath = ".//label[contains(.,'" . $activity . "')]/input";
-        $activitynode = $this->find('xpath', $activityxpath);
-        $activitynode->doubleClick();
+        if ($this->running_javascript()) {
+
+            // Clicks add activity or resource section link.
+            $sectionxpath = $sectionxpath . "/descendant::div[@class='section-modchooser']/span/a";
+            $sectionnode = $this->find('xpath', $sectionxpath);
+            $sectionnode->click();
+
+            // Clicks the selected activity if it exists.
+            $activityxpath = ".//label[contains(.,'" . $activity . "')]/input";
+            $activitynode = $this->find('xpath', $activityxpath);
+            $activitynode->doubleClick();
+
+        } else {
+            // Without Javascript.
+
+            // Selecting the option from the select box which contains the option.
+            $selectxpath = $sectionxpath . "/descendant::div[contains(concat(' ', @class, ' '), ' section_add_menus ')]
+/descendant::select[contains(., '" . $activity . "')]";
+            $selectnode = $this->find('xpath', $selectxpath);
+            $selectnode->selectOption($activity);
+
+            // Go button.
+            $gobuttonxpath = $selectxpath . "/ancestor::form/descendant::input[@type='submit']";
+            $gobutton = $this->find('xpath', $gobuttonxpath);
+            $gobutton->click();
+        }
+
+    }
+
+    /**
+     * Turns course section highlighting on.
+     *
+     * @Given /^I turn section "(?P<section_number>\d+)" highlighting on$/
+     * @param int $sectionnumber The section number
+     */
+    public function i_turn_section_highlighting_on($sectionnumber) {
+
+        // Ensures the section exists.
+        $xpath = $this->section_exists($sectionnumber);
+
+        return array(
+            new Given('I click on "' . get_string('markthistopic') . '" "link" in the "' . $xpath . '" "xpath_element"'),
+            new Given('I wait "2" seconds')
+        );
+    }
+
+    /**
+     * Turns course section highlighting off.
+     *
+     * @Given /^I turn section "(?P<section_number>\d+)" highlighting off$/
+     * @param int $sectionnumber The section number
+     */
+    public function i_turn_section_highlighting_off($sectionnumber) {
+
+        // Ensures the section exists.
+        $xpath = $this->section_exists($sectionnumber);
+
+        return array(
+            new Given('I click on "' . get_string('markedthistopic') . '" "link" in the "' . $xpath . '" "xpath_element"'),
+            new Given('I wait "2" seconds')
+        );
+    }
+
+    /**
+     * Shows the specified hidden section. You need to be in the course page and on editing mode.
+     *
+     * @Given /^I show section "(?P<section_number>\d+)"$/
+     * @param int $sectionnumber
+     */
+    public function i_show_section($sectionnumber) {
+        $showicon = $this->show_section_icon_exists($sectionnumber);
+        $showicon->click();
+
+        // It requires time.
+        $this->getSession()->wait(5000, false);
+    }
+
+    /**
+     * Hides the specified visible section. You need to be in the course page and on editing mode.
+     *
+     * @Given /^I hide section "(?P<section_number>\d+)"$/
+     * @param int $sectionnumber
+     */
+    public function i_hide_section($sectionnumber) {
+        $hideicon = $this->hide_section_icon_exists($sectionnumber);
+        $hideicon->click();
+
+        // It requires time.
+        $this->getSession()->wait(5000, false);
+    }
+
+    /**
+     * Checks if the specified course section hightlighting is turned on. You need to be in the course page on editing mode.
+     *
+     * @Then /^section "(?P<section_number>\d+)" should be highlighted$/
+     * @throws ExpectationException
+     * @param int $sectionnumber The section number
+     */
+    public function section_should_be_highlighted($sectionnumber) {
+
+        // Ensures the section exists.
+        $xpath = $this->section_exists($sectionnumber);
+
+        // The important checking, we can not check the img.
+        $xpath = $xpath . "/descendant::img[@alt='" . get_string('markedthistopic') . "'][contains(@src, 'marked')]";
+        $exception = new ExpectationException('The "' . $sectionnumber . '" section is not highlighted', $this->getSession());
+        $this->find('xpath', $xpath, $exception);
+    }
+
+    /**
+     * Checks if the specified course section highlighting is turned off. You need to be in the course page on editing mode.
+     *
+     * @Then /^section "(?P<section_number>\d+)" should not be highlighted$/
+     * @throws ExpectationException
+     * @param int $sectionnumber The section number
+     */
+    public function section_should_not_be_highlighted($sectionnumber) {
+
+        // We only catch ExpectationException, ElementNotFoundException should be thrown if the specified section does not exist.
+        try {
+            $this->section_should_be_highlighted($sectionnumber);
+        } catch (ExpectationException $e) {
+            // ExpectedException means that it is not highlighted.
+            return;
+        }
+
+        throw new ExpectationException('The "' . $sectionnumber . '" section is highlighted', $this->getSession());
+    }
+
+    /**
+     * Checks that the specified section is visible. You need to be in the course page. It can be used being logged as a student and as a teacher on editing mode.
+     *
+     * @Then /^section "(?P<section_number>\d+)" should be hidden$/
+     * @throws ExpectationException
+     * @throws ElementNotFoundException Thrown by behat_base::find
+     * @param int $sectionnumber
+     */
+    public function section_should_be_hidden($sectionnumber) {
+
+        $sectionxpath = $this->section_exists($sectionnumber);
+
+        // Section should be hidden.
+        $exception = new ExpectationException('The section is not hidden', $this->getSession());
+        $this->find('xpath', $sectionxpath . "[contains(concat(' ', @class, ' '), ' hidden ')]", $exception);
+
+        // The checking are different depending on user permissions.
+        if ($this->is_course_editor()) {
+
+            // The section must be hidden.
+            $this->show_section_icon_exists($sectionnumber);
+
+            // If there are activities they should be hidden and the visibility icon should not be available.
+            if ($activities = $this->get_section_activities($sectionxpath)) {
+
+                $dimmedexception = new ExpectationException('There are activities that are not dimmed', $this->getSession());
+                $visibilityexception = new ExpectationException('There are activities which visibility icons are clickable', $this->getSession());
+                foreach ($activities as $activity) {
+
+                    // Dimmed.
+                    $this->find('xpath', "//div[contains(concat(' ', @class, ' '), ' activityinstance ')]
+/a[contains(concat(' ', @class, ' '), ' dimmed ')]", $dimmedexception, $activity);
+
+                    // To check that the visibility is not clickable we check the funcionality rather than the applied style.
+                    $visibilityiconnode = $this->find('css', 'a.editing_show img', false, $activity);
+                    $visibilityiconnode->click();
+
+                    // We ensure that we still see the show icon.
+                    $visibilityiconnode = $this->find('css', 'a.editing_show img', $visibilityexception, $activity);
+                }
+            }
+
+        } else {
+            // There shouldn't be activities.
+            if ($this->get_section_activities($sectionxpath)) {
+                throw new ExpectationException('There are activities in the section and they should be hidden', $this->getSession());
+            }
+        }
+    }
+
+    /**
+     * Checks that the specified section is visible. You need to be in the course page. It can be used being logged as a student and as a teacher on editing mode.
+     *
+     * @Then /^section "(?P<section_number>\d+)" should be visible$/
+     * @throws ExpectationException
+     * @param int $sectionnumber
+     */
+    public function section_should_be_visible($sectionnumber) {
+
+        $sectionxpath = $this->section_exists($sectionnumber);
+
+        // Section should not be hidden.
+        if (!$this->getSession()->getPage()->find('xpath', $sectionxpath . "[not(contains(concat(' ', @class, ' '), ' hidden '))]")) {
+            throw new ExpectationException('The section is hidden', $this->getSession());
+        }
+
+        // Hide section button should be visible.
+        if ($this->is_course_editor()) {
+            $this->hide_section_icon_exists($sectionnumber);
+        }
+    }
+
+    /**
+     * Checks if the course section exists.
+     *
+     * @throws ElementNotFoundException Thrown by behat_base::find
+     * @param int $sectionnumber
+     * @return string The xpath of the section.
+     */
+    protected function section_exists($sectionnumber) {
+
+        // Just to give more info in case it does not exist.
+        $xpath = "//li[@id='section-" . $sectionnumber . "']";
+        $exception = new ElementNotFoundException($this->getSession(), "Section $sectionnumber ");
+        $this->find('xpath', $xpath, $exception);
+
+        return $xpath;
+    }
+
+    /**
+     * Returns the show section icon or throws an exception.
+     *
+     * @throws ElementNotFoundException Thrown by behat_base::find
+     * @param int $sectionnumber
+     * @return NodeElement
+     */
+    protected function show_section_icon_exists($sectionnumber) {
+
+        // Gets the section xpath and ensure it exists.
+        $xpath = $this->section_exists($sectionnumber);
+
+        // We need to know the course format as the text strings depends on them.
+        $courseformat = $this->get_course_format();
+
+        // Checking the show button alt text and show icon.
+        $xpath = $xpath . "/descendant::a/descendant::img[@alt='". get_string('showfromothers', $courseformat) ."'][contains(@src, 'show')]";
+
+        $exception = new ElementNotFoundException($this->getSession(), 'Show section icon ');
+        return $this->find('xpath', $xpath, $exception);
+    }
+
+    /**
+     * Returns the hide section icon link if it exists or throws exception.
+     *
+     * @throws ElementNotFoundException Thrown by behat_base::find
+     * @param int $sectionnumber
+     * @return NodeElement
+     */
+    protected function hide_section_icon_exists($sectionnumber) {
+
+        // Gets the section xpath and ensure it exists.
+        $xpath = $this->section_exists($sectionnumber);
+
+        // We need to know the course format as the text strings depends on them.
+        $courseformat = $this->get_course_format();
+
+        // Checking the hide button alt text and hide icon.
+        $xpath = $xpath . "/descendant::a/descendant::img[@alt='". get_string('hidefromothers', $courseformat) ."'][contains(@src, 'hide')]";
+
+        $exception = new ElementNotFoundException($this->getSession(), 'Hide section icon ');
+        return $this->find('xpath', $xpath, $exception);
+    }
+
+    /**
+     * Gets the current course format.
+     *
+     * @throws ExpectationException If we are not in the course view page.
+     * @return string The course format in a frankenstyled name.
+     */
+    protected function get_course_format() {
+
+        $exception = new ExpectationException('You are not in a course page', $this->getSession());
+
+        // The moodle body's id attribute contains the course format.
+        $node = $this->getSession()->getPage()->find('css', 'body');
+        if (!$node) {
+            throw $exception;
+        }
+
+        if (!$bodyid = $node->getAttribute('id')) {
+            throw $exception;
+        }
+
+        if (strstr($bodyid, 'page-course-view-') === false) {
+            throw $exception;
+        }
+
+        return 'format_' . str_replace('page-course-view-', '', $bodyid);
+    }
+
+    /**
+     * Gets the section's activites DOM nodes.
+     *
+     * @param string $sectionxpath
+     * @return array NodeElement instances
+     */
+    protected function get_section_activities($sectionxpath) {
+
+        $xpath = $sectionxpath . "/descendant::li[contains(concat(' ', @class, ' '), ' activity ')]";
+
+        // We spin here, as activities usually require a lot of time to load.
+        try {
+            $activities = $this->find_all('xpath', $xpath);
+        } catch (ElementNotFoundException $e) {
+            return false;
+        }
+
+        return $activities;
+    }
+
+    /**
+     * Returns whether the user can edit the course contents or not.
+     *
+     * @return bool
+     */
+    protected function is_course_editor() {
+
+        // We don't need to behat_base::spin() here as all is already loaded.
+        if (!$this->getSession()->getPage()->findButton('Turn editing off') &&
+                !$this->getSession()->getPage()->findButton('Turn editing on')) {
+            return false;
+        }
+
+        return true;
     }
 
 }
diff --git a/course/tests/behat/force_group_mode.feature b/course/tests/behat/force_group_mode.feature
new file mode 100644 (file)
index 0000000..2ddccd5
--- /dev/null
@@ -0,0 +1,60 @@
+@core_course @_cross_browser
+Feature: Force group mode in a course
+  In order to use the same group mode all over the course
+  As a moodle teacher
+  I need to force the group mode of all course's activities
+
+  Background:
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "chat" to section "1" and I fill the form with:
+      | Name of this chat room | Chat room |
+      | Description | Chat description |
+    And I follow "Edit settings"
+
+  @javascript
+  Scenario: Forced group mode using separate groups
+    Given I fill the moodle form with:
+      | Group mode | Separate groups |
+      | Force group mode | Yes |
+    When I press "Save changes"
+    Then ".//a//img[contains(./@alt, 'Separate groups (forced mode)')]" "xpath_element" should not exists
+    And ".//img[contains(./@alt, 'Separate groups (forced mode)')]" "xpath_element" should exists
+    And I click on "//img[contains(./@alt, 'Separate groups (forced mode)')]" "xpath_element" in the "li.activity.chat" "css_element"
+    And ".//a//img[contains(./@alt, 'Separate groups (forced mode)')]" "xpath_element" should not exists
+    And ".//img[contains(./@alt, 'Separate groups (forced mode)')]" "xpath_element" should exists
+
+  @javascript
+  Scenario: Forced group mode using visible groups
+    Given I fill the moodle form with:
+      | Group mode | Visible groups |
+      | Force group mode | Yes |
+    And I press "Save changes"
+    Then ".//a//img[contains(./@alt, 'Visible groups (forced mode)')]" "xpath_element" should not exists
+    And ".//img[contains(./@alt, 'Visible groups (forced mode)')]" "xpath_element" should exists
+    And I click on "//img[contains(./@alt, 'Visible groups (forced mode)')]" "xpath_element" in the "li.activity.chat" "css_element"
+    And ".//a//img[contains(./@alt, 'Visible groups (forced mode)')]" "xpath_element" should not exists
+    And ".//img[contains(./@alt, 'Visible groups (forced mode)')]" "xpath_element" should exists
+
+  @javascript
+  Scenario: Forced group mode without groups
+    Given I fill the moodle form with:
+      | Group mode | No groups |
+      | Force group mode | Yes |
+    And I press "Save changes"
+    Then ".//a//img[contains(./@alt, 'No groups (forced mode)')]" "xpath_element" should not exists
+    And ".//img[contains(./@alt, 'No groups (forced mode)')]" "xpath_element" should exists
+    And I click on "//img[contains(./@alt, 'No groups (forced mode)')]" "xpath_element" in the "li.activity.chat" "css_element"
+    And ".//a//img[contains(./@alt, 'No groups (forced mode)')]" "xpath_element" should not exists
+    And ".//img[contains(./@alt, 'No groups (forced mode)')]" "xpath_element" should exists
+
diff --git a/course/tests/behat/section_highlighting.feature b/course/tests/behat/section_highlighting.feature
new file mode 100644 (file)
index 0000000..dfb5ab5
--- /dev/null
@@ -0,0 +1,43 @@
+@core_course @_cross_browser
+Feature: Topic's course sections highlighting
+  In order to highlight parts of the course to students
+  As a teacher
+  I need to highlight one specific section
+
+  @javascript
+  Scenario: Highlight a topic's course section
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+      | student1 | Student | 1 | student1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    When I turn section "1" highlighting on
+    Then section "1" should be highlighted
+    And I turn section "2" highlighting on
+    And section "2" should be highlighted
+    And section "1" should not be highlighted
+    And I am on homepage
+    And I follow "Course 1"
+    And section "2" should be highlighted
+    And section "1" should not be highlighted
+    And I turn section "2" highlighting off
+    And section "2" should not be highlighted
+    And I reload the page
+    And section "2" should not be highlighted
+    And I am on homepage
+    And I follow "Course 1"
+    And section "2" should not be highlighted
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And section "1" should not be highlighted
+    And section "2" should not be highlighted
diff --git a/course/tests/behat/section_visibility.feature b/course/tests/behat/section_visibility.feature
new file mode 100644 (file)
index 0000000..dad5d8c
--- /dev/null
@@ -0,0 +1,69 @@
+@core_course @_cross_browser
+Feature: Show/hide course sections
+  In order to delay sections availability
+  As a moodle teacher
+  I need to show or hide sections
+
+  @javascript
+  Scenario: Show / hide section icon functions correctly
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+      | student1 | Student | 1 | student1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "forum" to section "1" and I fill the form with:
+      | Forum name | Test hidden forum 11 name |
+      | Description | Test hidden forum 11 description |
+      | Visible | Hide |
+    And I add a "forum" to section "1" and I fill the form with:
+      | Forum name | Test hidden forum 12 name |
+      | Description | Test hidden forum 12 description |
+      | Visible | Show |
+    And I add a "forum" to section "2" and I fill the form with:
+      | Forum name | Test hidden forum 21 name |
+      | Description | Test hidden forum 21 description |
+      | Visible | Hide |
+    And I add a "forum" to section "2" and I fill the form with:
+      | Forum name | Test hidden forum 22 name |
+      | Description | Test hidden forum 22 description |
+      | Visible | Show |
+    And I add a "forum" to section "3" and I fill the form with:
+      | Forum name | Test hidden forum 31 name |
+      | Description | Test hidden forum 31 description |
+      | Visible | Hide |
+    And I add a "forum" to section "3" and I fill the form with:
+      | Forum name | Test hidden forum 32 name |
+      | Description | Test hidden forum 32 description |
+      | Visible | Show |
+    And I follow "Course 1"
+    When I hide section "1"
+    Then section "1" should be hidden
+    And section "2" should be visible
+    And section "3" should be visible
+    And I hide section "2"
+    And section "2" should be hidden
+    And I show section "2"
+    And section "2" should be visible
+    And I hide section "3"
+    And I show section "3"
+    And I hide section "3"
+    And section "3" should be hidden
+    And I reload the page
+    And section "1" should be hidden
+    And section "2" should be visible
+    And section "3" should be hidden
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And section "1" should be hidden
+    And section "2" should be visible
+    And section "3" should be hidden
index 9d08906..d688159 100644 (file)
@@ -1112,4 +1112,137 @@ class courselib_testcase extends advanced_testcase {
             $this->assertGreaterThanOrEqual($last, $activity->timestamp);
         }
     }
+
+    /**
+     * Tests moving a module between hidden/visible sections and
+     * verifies that the course/module visiblity seettings are
+     * retained.
+     */
+    public function test_moveto_module_between_hidden_sections() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course(array('numsections' => 4), array('createsections' => true));
+        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id));
+        $page = $this->getDataGenerator()->create_module('page', array('course' => $course->id));
+        $quiz= $this->getDataGenerator()->create_module('quiz', array('course' => $course->id));
+
+        // Set the page as hidden
+        set_coursemodule_visible($page->cmid, 0);
+
+        // Set sections 3 as hidden.
+        set_section_visible($course->id, 3, 0);
+
+        $modinfo = get_fast_modinfo($course);
+
+        $hiddensection = $modinfo->get_section_info(3);
+        // New section is definitely not visible:
+        $this->assertEquals($hiddensection->visible, 0);
+
+        $forumcm = $modinfo->cms[$forum->cmid];
+        $pagecm = $modinfo->cms[$page->cmid];
+
+        // Move the forum and the page to a hidden section.
+        moveto_module($forumcm, $hiddensection);
+        moveto_module($pagecm, $hiddensection);
+
+        // Reset modinfo cache.
+        get_fast_modinfo(0, 0, true);
+
+        $modinfo = get_fast_modinfo($course);
+
+        // Verify that forum and page have been moved to the hidden section and quiz has not.
+        $this->assertContains($forum->cmid, $modinfo->sections[3]);
+        $this->assertContains($page->cmid, $modinfo->sections[3]);
+        $this->assertNotContains($quiz->cmid, $modinfo->sections[3]);
+
+        // Verify that forum has been made invisible.
+        $forumcm = $modinfo->cms[$forum->cmid];
+        $this->assertEquals($forumcm->visible, 0);
+        // Verify that old state has been retained.
+        $this->assertEquals($forumcm->visibleold, 1);
+
+        // Verify that page has stayed invisible.
+        $pagecm = $modinfo->cms[$page->cmid];
+        $this->assertEquals($pagecm->visible, 0);
+        // Verify that old state has been retained.
+        $this->assertEquals($pagecm->visibleold, 0);
+
+        // Verify that quiz has been unaffected.
+        $quizcm = $modinfo->cms[$quiz->cmid];
+        $this->assertEquals($quizcm->visible, 1);
+
+        // Move forum and page back to visible section.
+        $visiblesection = $modinfo->get_section_info(2);
+        moveto_module($forumcm, $visiblesection);
+        moveto_module($pagecm, $visiblesection);
+
+        // Reset modinfo cache.
+        get_fast_modinfo(0, 0, true);
+        $modinfo = get_fast_modinfo($course);
+
+        // Verify that forum has been made visible.
+        $forumcm = $modinfo->cms[$forum->cmid];
+        $this->assertEquals($forumcm->visible, 1);
+
+        // Verify that page has stayed invisible.
+        $pagecm = $modinfo->cms[$page->cmid];
+        $this->assertEquals($pagecm->visible, 0);
+
+        // Move the page in the same section (this is what mod duplicate does_
+        moveto_module($pagecm, $visiblesection, $forumcm);
+
+        // Reset modinfo cache.
+        get_fast_modinfo(0, 0, true);
+
+        // Verify that the the page is still hidden
+        $modinfo = get_fast_modinfo($course);
+        $pagecm = $modinfo->cms[$page->cmid];
+        $this->assertEquals($pagecm->visible, 0);
+    }
+
+    /**
+     * Tests moving a module around in the same section. moveto_module()
+     * is called this way in modduplicate.
+     */
+    public function test_moveto_module_in_same_section() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course(array('numsections' => 3), array('createsections' => true));
+        $page = $this->getDataGenerator()->create_module('page', array('course' => $course->id));
+        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id));
+
+        // Simulate inconsistent visible/visibleold values (MDL-38713).
+        $cm = $DB->get_record('course_modules', array('id' => $page->cmid), '*', MUST_EXIST);
+        $cm->visible = 0;
+        $cm->visibleold = 1;
+        $DB->update_record('course_modules', $cm);
+
+        $modinfo = get_fast_modinfo($course);
+        $forumcm = $modinfo->cms[$forum->cmid];
+        $pagecm = $modinfo->cms[$page->cmid];
+
+        // Verify that page is hidden.
+        $this->assertEquals($pagecm->visible, 0);
+
+        // Verify section 0 is where all mods added.
+        $section = $modinfo->get_section_info(0);
+        $this->assertEquals($section->id, $forumcm->section);
+        $this->assertEquals($section->id, $pagecm->section);
+
+
+        // Move the forum and the page to a hidden section.
+        moveto_module($pagecm, $section, $forumcm);
+
+        // Reset modinfo cache.
+        get_fast_modinfo(0, 0, true);
+
+        // Verify that the the page is still hidden
+        $modinfo = get_fast_modinfo($course);
+        $pagecm = $modinfo->cms[$page->cmid];
+        $this->assertEquals($pagecm->visible, 0);
+    }
 }
index 5917d88..ece712b 100644 (file)
@@ -104,7 +104,7 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
                 $exceptionlocator = $locator[1];
 
                 // If we are in a @javascript session all contents would be displayed as HTML characters.
-                if (get_class($this->getSession()->getDriver()) === 'Behat\Mink\Driver\Selenium2Driver') {
+                if ($this->running_javascript()) {
                     $locator[1] = html_entity_decode($locator[1], ENT_NOQUOTES);
                 }
 
@@ -405,4 +405,13 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
         return $this->transform_selector($selectortype, $element);
     }
 
+    /**
+     * Returns whether the scenario is running in a browser that can run Javascript or not.
+     *
+     * @return boolean
+     */
+    protected function running_javascript() {
+        return get_class($this->getSession()->getDriver()) === 'Moodle\BehatExtension\Driver\MoodleSelenium2Driver';
+    }
+
 }
index 01cc965..a72e753 100644 (file)
@@ -53,7 +53,6 @@ class behat_field_manager {
      * @return behat_form_field
      */
     public static function get_field(NodeElement $fieldnode, $locator, Session $session) {
-        global $CFG;
 
         // Get the field type if is part of a moodleform.
         if (self::is_moodleform_field($fieldnode)) {
@@ -65,6 +64,23 @@ class behat_field_manager {
             $type = 'field';
         }
 
+        return self::get_field_instance($type, $fieldnode, $session);
+    }
+
+    /**
+     * Returns the appropiate behat_form_field according to the provided type.
+     *
+     * It defaults to behat_form_field.
+     *
+     * @param string $type The field type (checkbox, date_selector, text...)
+     * @param NodeElement $fieldnode
+     * @param Session $session The behat session
+     * @return behat_form_field
+     */
+    public static function get_field_instance($type, NodeElement $fieldnode, Session $session) {
+
+        global $CFG;
+
         $classname = 'behat_form_' . $type;
 
         // Fallsback on the default form field if nothing specific exists.
index 938bde6..8ba838a 100644 (file)
@@ -69,7 +69,14 @@ class behat_form_field {
      * @return void
      */
     public function set_value($value) {
-        $this->field->setValue($value);
+
+        // If we are not dealing with a text-based tag try to find the most appropiate
+        // behat_form_* class to deal with it.
+        if ($instance = $this->guess_type()) {
+            $instance->set_value($value);
+        } else {
+            $this->field->setValue($value);
+        }
     }
 
     /**
@@ -78,7 +85,62 @@ class behat_form_field {
      * @return string
      */
     public function get_value() {
-        return $this->field->getValue();
+
+        // If we are not dealing with a text-based tag try to find the most appropiate
+        // behat_form_* class to deal with it.
+        if ($instance = $this->guess_type()) {
+            return $instance->get_value();
+        } else {
+            return $this->field->getValue();
+        }
+    }
+
+    /**
+     * Guesses the element type we are dealing with in case is not a text-based element.
+     *
+     * This class is the generic field type, behat_field_manager::get_field()
+     * should be able to find the appropiate class for the field type, but
+     * in cases like moodle form group elements we can not find the type of
+     * the field through the DOM so we also need to take care of the
+     * different field types from here. If we need to deal with more complex
+     * moodle form elements we will need to refactor this simple HTML elements
+     * guess method.
+     *
+     * @return mixed False if no need for an special behat_form_*, otherwise the behat_form_*
+     */
+    private function guess_type() {
+        global $CFG;
+
+        // Textareas are considered text based elements.
+        $tagname = $this->field->getTagName();
+        if ($tagname == 'textarea') {
+            return false;
+        }
+
+        if ($tagname == 'input') {
+            $type = $this->field->getAttribute('type');
+            switch ($type) {
+                case 'text':
+                    return false;
+                case 'checkbox':
+                    $classname = 'behat_form_checkbox';
+                    break;
+                case 'radio':
+                    // Behaves the same way.
+                    $classname = 'behat_form_checkbox';
+                    break;
+                default:
+                    return false;
+            }
+        }
+
+        // Select tag.
+        if ($tagname == 'select') {
+            $classname = 'behat_form_select';
+        }
+
+        $classpath = $CFG->dirroot . '/lib/behat/form_field/' . $classname . '.php';
+        return new $classname($this->session, $this->field);
     }
 
 }
diff --git a/lib/behat/form_field/behat_form_modvisible.php b/lib/behat/form_field/behat_form_modvisible.php
new file mode 100644 (file)
index 0000000..04e65ff
--- /dev/null
@@ -0,0 +1,41 @@
+<?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/>.
+
+/**
+ * Silly behat_form_select extension.
+ *
+ * @package    core_form
+ * @category   test
+ * @copyright  2013 David Monllaó
+ * @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__  . '/behat_form_select.php');
+
+/**
+ * Allows interaction with fmodvisible form fields.
+ *
+ * Plain behat_form_select extension as is the same
+ * kind of field.
+ *
+ * @package    core_form
+ * @category   test
+ * @copyright  2013 David Monllaó
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_form_modvisible extends behat_form_select {}
index fd82d95..b40cdb0 100644 (file)
@@ -176,7 +176,9 @@ class behat_forms extends behat_base {
         $selectnode->selectOption($option);
 
         // Adding a click as Selenium requires it to fire some JS events.
-        $selectnode->click();
+        if ($this->running_javascript()) {
+            $selectnode->click();
+        }
     }
 
     /**
@@ -192,7 +194,9 @@ class behat_forms extends behat_base {
         $radionode->check();
 
         // Adding a click as Selenium requires it to fire some JS events.
-        $radionode->click();
+        if ($this->running_javascript()) {
+            $radionode->click();
+        }
     }
 
     /**
index 67d773e..ea6b623 100644 (file)
@@ -27,7 +27,8 @@
 
 require_once(__DIR__ . '/../../behat/behat_base.php');
 
-use Behat\Mink\Exception\ExpectationException as ExpectationException;
+use Behat\Mink\Exception\ExpectationException as ExpectationException,
+    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
 
 /**
  * Cross component steps definitions.
@@ -53,6 +54,15 @@ class behat_general extends behat_base {
         $this->getSession()->visit($this->locate_path('/'));
     }
 
+    /**
+     * Reloads the current page.
+     *
+     * @Given /^I reload the page$/
+     */
+    public function reload() {
+        $this->getSession()->reload();
+    }
+
     /**
      * Clicks link with specified id|title|alt|text.
      *
@@ -180,6 +190,60 @@ class behat_general extends behat_base {
         $this->assertSession()->elementTextNotContains($selector, $locator, $text);
     }
 
+    /**
+     * Checks, that the first specified element appears before the second one.
+     *
+     * @Given /^"(?P<preceding_element_string>(?:[^"]|\\")*)" "(?P<selector1_string>(?:[^"]|\\")*)" should appear before "(?P<following_element_string>(?:[^"]|\\")*)" "(?P<selector2_string>(?:[^"]|\\")*)"$/
+     * @throws ExpectationException
+     * @param string $preelement The locator of the preceding element
+     * @param string $preselectortype The locator of the preceding element
+     * @param string $postelement The locator of the latest element
+     * @param string $postselectortype The selector type of the latest element
+     */
+    public function should_appear_before($preelement, $preselectortype, $postelement, $postselectortype) {
+
+        // We allow postselectortype as a non-text based selector.
+        list($preselector, $prelocator) = $this->transform_selector($preselectortype, $preelement);
+        list($postselector, $postlocator) = $this->transform_selector($postselectortype, $postelement);
+
+        $prexpath = $this->find($preselector, $prelocator)->getXpath();
+        $postxpath = $this->find($postselector, $postlocator)->getXpath();
+
+        // Using following xpath axe to find it.
+        $msg = '"'.$preelement.'" "'.$preselectortype.'" does not appear before "'.$postelement.'" "'.$postselectortype.'"';
+        $xpath = $prexpath.'/following::*[contains(., '.$postxpath.')]';
+        if (!$this->getSession()->getDriver()->find($xpath)) {
+            throw new ExpectationException($msg, $this->getSession());
+        }
+    }
+
+    /**
+     * Checks, that the first specified element appears after the second one.
+     *
+     * @Given /^"(?P<following_element_string>(?:[^"]|\\")*)" "(?P<selector1_string>(?:[^"]|\\")*)" should appear after "(?P<preceding_element_string>(?:[^"]|\\")*)" "(?P<selector2_string>(?:[^"]|\\")*)"$/
+     * @throws ExpectationException
+     * @param string $postelement The locator of the latest element
+     * @param string $postselectortype The selector type of the latest element
+     * @param string $preelement The locator of the preceding element
+     * @param string $preselectortype The locator of the preceding element
+     */
+    public function should_appear_after($postelement, $postselectortype, $preelement, $preselectortype) {
+
+        // We allow postselectortype as a non-text based selector.
+        list($postselector, $postlocator) = $this->transform_selector($postselectortype, $postelement);
+        list($preselector, $prelocator) = $this->transform_selector($preselectortype, $preelement);
+
+        $postxpath = $this->find($postselector, $postlocator)->getXpath();
+        $prexpath = $this->find($preselector, $prelocator)->getXpath();
+
+        // Using preceding xpath axe to find it.
+        $msg = '"'.$postelement.'" "'.$postselectortype.'" does not appear after "'.$preelement.'" "'.$preselectortype.'"';
+        $xpath = $postxpath.'/preceding::*[contains(., '.$prexpath.')]';
+        if (!$this->getSession()->getDriver()->find($xpath)) {
+            throw new ExpectationException($msg, $this->getSession());
+        }
+    }
+
     /**
      * Checks, that element of specified type is disabled.
      *
@@ -216,4 +280,44 @@ class behat_general extends behat_base {
         }
     }
 
+    /**
+     * Checks the provided element and selector type exists in the current page.
+     *
+     * This step is for advanced users, use it if you don't find anything else suitable for what you need.
+     *
+     * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should exists$/
+     * @throws ElementNotFoundException Thrown by behat_base::find
+     * @param string $element The locator of the specified selector
+     * @param string $selectortype The selector type
+     */
+    public function should_exists($element, $selectortype) {
+
+        // Getting Mink selector and locator.
+        list($selector, $locator) = $this->transform_selector($selectortype, $element);
+
+        // Will throw an ElementNotFoundException if it does not exist.
+        $this->find($selector, $locator);
+    }
+
+    /**
+     * Checks that the provided element and selector type not exists in the current page.
+     *
+     * This step is for advanced users, use it if you don't find anything else suitable for what you need.
+     *
+     * @Then /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should not exists$/
+     * @throws ExpectationException
+     * @param string $element The locator of the specified selector
+     * @param string $selectortype The selector type
+     */
+    public function should_not_exists($element, $selectortype) {
+
+        try {
+            $this->should_exists($element, $selectortype);
+            throw new ExpectationException('The "' . $element . '" "' . $selectortype . '" exists in the current page', $this->getSession());
+        } catch (ElementNotFoundException $e) {
+            // It passes.
+            return;
+        }
+    }
+
 }
index 6257131..f228098 100644 (file)
@@ -1,2 +1,3 @@
-username,password,email,firstname,lastname
-teacher1,moodle,teacher1@teacher1.com,Teacher,1
+username,password,firstname,lastname,email,course1,group1
+jonest,verysecret,Tom,Jones,jonest@someplace.edu,math102,Section 1
+reznor,somesecret,Trent,Reznor,reznor@someplace.edu,math102,Section 3
diff --git a/mod/forum/tests/behat/add_forum.feature b/mod/forum/tests/behat/add_forum.feature
new file mode 100644 (file)
index 0000000..80466ea
--- /dev/null
@@ -0,0 +1,29 @@
+@mod_forum
+Feature: Add forum activities and discussions
+  In order to discuss topics with other users
+  As a moodle teacher
+  I need to add forum activities to moodle courses
+
+  @javascript
+  Scenario: Add a forum and a discussion
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Forum type | Standard forum for general use |
+      | Description | Test forum description |
+    When I add a new discussion to "Test forum name" forum with:
+      | Subject | Forum post 1 |
+      | Message | This is the body |
+    And I wait "6" seconds
+    Then I should see "Test forum name"
diff --git a/mod/forum/tests/behat/behat_mod_forum.php b/mod/forum/tests/behat/behat_mod_forum.php
new file mode 100644 (file)
index 0000000..8ddf100
--- /dev/null
@@ -0,0 +1,80 @@
+<?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 with the forum activity.
+ *
+ * @package    mod_forum
+ * @category   test
+ * @copyright  2013 David Monllaó
+ * @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');
+
+use Behat\Behat\Context\Step\Given as Given,
+    Behat\Gherkin\Node\TableNode as TableNode;
+/**
+ * Forum-related steps definitions.
+ *
+ * @package    mod_forum
+ * @category   test
+ * @copyright  2013 David Monllaó
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_mod_forum extends behat_base {
+
+    /**
+     * Adds a discussion to the forum specified by it's name with the provided table data (usually Subject and Message). The step begins from the forum's course page.
+     *
+     * @Given /^I add a new discussion to "(?P<forum_name_string>(?:[^"]|\\")*)" forum with:$/
+     * @param string $forumname
+     * @param TableNode $table
+     */
+    public function i_add_a_forum_discussion_to_forum_with($forumname, TableNode $table) {
+
+        // Escaping $forumname as it has been stripped automatically by the transformer.
+        return array(
+            new Given('I follow "' . $this->escape($forumname) . '"'),
+            new Given('I press "Add a new discussion topic"'),
+            new Given('I fill the moodle form with:', $table),
+            new Given('I press "Post to forum"'),
+            new Given('I wait "5" seconds')
+        );
+    }
+
+    /**
+     * Adds a reply to the specified post of the specified forum. The step begins from the forum's page or from the forum's course page.
+     *
+     * @Given /^I reply "(?P<post_subject_string>(?:[^"]|\\")*)" post from "(?P<forum_name_string>(?:[^"]|\\")*)" forum with:$/
+     * @param mixed $postname The subject of the post
+     * @param mixed $forumname The forum name
+     * @param TableNode $table
+     */
+    public function i_reply_post_from_forum_with($postsubject, $forumname, TableNode $table) {
+
+        return array(
+            new Given('I follow "' . $this->escape($forumname) . '"'),
+            new Given('I follow "' . $this->escape($postsubject) . '"'),
+            new Given('I follow "Reply"'),
+            new Given('I fill the moodle form with:', $table),
+            new Given('I press "Post to forum"'),
+            new Given('I wait "5" seconds')
+        );
+    }
+}
diff --git a/mod/forum/tests/behat/completion_condition_number_discussions.feature b/mod/forum/tests/behat/completion_condition_number_discussions.feature
new file mode 100644 (file)
index 0000000..eb0c914
--- /dev/null
@@ -0,0 +1,54 @@
+@mod_forum
+Feature: Set a certain number of discussions as a completion condition for a forum
+  In order to ensure students are participating on forums
+  As a moodle teacher
+  I need to set a minimum number of discussions to mark the forum activity as completed
+
+  @javascript
+  Scenario: Set X number of discussions as a condition
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | student1 | Student | 1 | student1@asd.com |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "admin"
+    And I set the following administration settings values:
+      | Enable completion tracking | 1 |
+      | Enable conditional access | 1 |
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I follow "Edit settings"
+    And I fill the moodle form with:
+      | Completion tracking | Enabled, control via completion and activity settings |
+      | Completion tracking begins on enrolment | 1 |
+    And I press "Save changes"
+    When I add a "Forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Description | Test forum description |
+      | Completion tracking | Show activity as complete when conditions are met |
+      | completiondiscussionsenabled | 1 |
+      | completiondiscussions | 2 |
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    Then I hover "//li[contains(concat(' ', @class, ' '), ' modtype_forum ')]/descendant::img[@alt='Not completed: Test forum name']" "xpath_element"
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Post 1 subject |
+      | Message | Body 1 content |
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Post 2 subject |
+      | Message | Body 2 content |
+    And I follow "Course 1"
+    And I hover "//li[contains(concat(' ', @class, ' '), ' modtype_forum ')]/descendant::img[contains(@alt, 'Completed: Test forum name')]" "xpath_element"
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And "Student 1" user has completed "Test forum name" activity
diff --git a/mod/forum/tests/behat/discussion_display.feature b/mod/forum/tests/behat/discussion_display.feature
new file mode 100644 (file)
index 0000000..5d5d77e
--- /dev/null
@@ -0,0 +1,93 @@
+@mod_forum
+Feature: Students can choose from 4 discussion display options and their choice is remembered
+  In order to read forum posts in a suitable view
+  As a moodle user
+  I need to select which display method I want to use
+
+  Background:
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | student1 | Student | 1 | student1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | student1 | C1 | student |
+    And I log in as "admin"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Description | Test forum description |
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Discussion 1 |
+      | Message | Discussion contents 1, first message |
+    And I reply "Discussion 1" post from "Test forum name" forum with:
+      | Subject | Reply 1 to discussion 1 |
+      | Message | Discussion contents 1, second message |
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Discussion 2 |
+      | Message | Discussion contents 2, first message |
+    And I reply "Discussion 2" post from "Test forum name" forum with:
+      | Subject | Reply 1 to discussion 2 |
+      | Message | Discussion contents 2, second message |
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+
+  @javascript
+  Scenario: Display replies flat, with oldest first
+    Given I reply "Discussion 1" post from "Test forum name" forum with:
+      | Subject | Reply 2 to discussion 1 |
+      | Message | Discussion contents 1, third message |
+    When I select "Display replies flat, with oldest first" from "mode"
+    Then I should see "Discussion contents 1, first message" in the "div.firstpost.starter" "css_element"
+    And I should see "Discussion contents 1, second message" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' forumpost ') and not(contains(@class, 'starter'))]" "xpath_element"
+    And I reply "Discussion 2" post from "Test forum name" forum with:
+      | Subject | Reply 2 to discussion 2 |
+      | Message | Discussion contents 2, third message |
+    And the "Display mode" field should match "1" value
+    And I should see "Discussion contents 2, first message" in the "div.firstpost.starter" "css_element"
+    And I should see "Discussion contents 2, second message" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' forumpost ') and not(contains(@class, 'starter'))]" "xpath_element"
+
+  @javascript
+  Scenario: Display replies flat, with newest first
+    Given I reply "Discussion 1" post from "Test forum name" forum with:
+      | Subject | Reply 2 to discussion 1 |
+      | Message | Discussion contents 1, third message |
+    When I select "Display replies flat, with newest first" from "mode"
+    Then I should see "Discussion contents 1, first message" in the "div.firstpost.starter" "css_element"
+    And I should see "Discussion contents 1, third message" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' forumpost ') and not(contains(@class, 'starter'))]" "xpath_element"
+    And I reply "Discussion 2" post from "Test forum name" forum with:
+      | Subject | Reply 2 to discussion 2 |
+      | Message | Discussion contents 2, third message |
+    And the "Display mode" field should match "-1" value
+    And I should see "Discussion contents 2, first message" in the "div.firstpost.starter" "css_element"
+    And I should see "Discussion contents 2, third message" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' forumpost ') and not(contains(@class, 'starter'))]" "xpath_element"
+
+  @javascript
+  Scenario: Display replies in threaded form
+    Given I follow "Test forum name"
+    And I follow "Discussion 1"
+    When I select "Display replies in threaded form" from "mode"
+    Then I should see "Discussion contents 1, first message"
+    And I should see "Reply 1 to discussion 1" in the "span.forumthread" "css_element"
+    And I follow "Test forum name"
+    And I follow "Discussion 2"
+    And the "Display mode" field should match "2" value
+    And I should see "Discussion contents 2, first message"
+    And I should see "Reply 1 to discussion 2" in the "span.forumthread" "css_element"
+
+  @javascript
+  Scenario: Display replies in nested form
+    Given I follow "Test forum name"
+    And I follow "Discussion 1"
+    When I select "Display replies in nested form" from "mode"
+    Then I should see "Discussion contents 1, first message" in the "div.firstpost.starter" "css_element"
+    And I should see "Discussion contents 1, second message" in the "div.indent div.forumpost" "css_element"
+    And I follow "Test forum name"
+    And I follow "Discussion 2"
+    And the "Display mode" field should match "3" value
+    And I should see "Discussion contents 2, first message" in the "div.firstpost.starter" "css_element"
+    And I should see "Discussion contents 2, second message" in the "div.indent div.forumpost" "css_element"
diff --git a/mod/forum/tests/behat/edit_post_student.feature b/mod/forum/tests/behat/edit_post_student.feature
new file mode 100644 (file)
index 0000000..e693af6
--- /dev/null
@@ -0,0 +1,62 @@
+@mod_forum
+Feature: Students can edit or delete their forum posts within a set time limit
+  In order to refine forum posts
+  As a moodle user
+  I need to edit or delete my forum posts within a certain period of time after posting
+
+  Background:
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | student1 | Student | 1 | student1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | student1 | C1 | student |
+    And I log in as "admin"
+    And I expand "Site administration" node
+    And I expand "Security" node
+    And I follow "Site policies"
+    And I select "1 minutes" from "Maximum time to edit posts"
+    And I press "Save changes"
+    And I am on homepage
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Forum type | Standard forum for general use |
+      | Description | Test forum description |
+    And I log out
+    And I follow "Course 1"
+    And I log in as "student1"
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Forum post subject |
+      | Message | This is the body |
+    And I wait "6" seconds
+
+  @javascript
+  Scenario: Edit forum post
+    When I follow "Forum post subject"
+    And I follow "Edit"
+    And I fill the moodle form with:
+      | Subject | Edited post subject |
+      | Message | Edited post body |
+    And I press "Save changes"
+    And I wait "6" seconds
+    Then I should see "Edited post subject"
+    And I should see "Edited post body"
+
+  @javascript
+  Scenario: Delete forum post
+    When I follow "Forum post subject"
+    And I follow "Delete"
+    And I press "Continue"
+    Then I should not see "Forum post subject"
+
+  @javascript
+  Scenario: Time limit expires
+    When I wait "70" seconds
+    And I follow "Forum post subject"
+    Then I should not see "Edit"
+    And I should not see "Delete"
diff --git a/mod/forum/tests/behat/edit_post_teacher.feature b/mod/forum/tests/behat/edit_post_teacher.feature
new file mode 100644 (file)
index 0000000..6d81fbd
--- /dev/null
@@ -0,0 +1,67 @@
+@mod_forum
+Feature: Teachers can edit or delete any forum post
+  In order to refine the forum contents
+  As a moodle teacher
+  I need to edit or delete any user's forum posts
+
+  Background:
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+      | student1 | Student | 1 | student1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Description | Test forum description |
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Teacher post subject |
+      | Message | Teacher post message |
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I reply "Teacher post subject" post from "Test forum name" forum with:
+      | Subject | Student post subject |
+      | Message | Student post message |
+
+  @javascript
+  Scenario: A teacher can delete another user's posts
+    Given I log out
+    And I log in as "teacher1"
+    When I follow "Course 1"
+    And I follow "Test forum name"
+    And I follow "Teacher post subject"
+    And I click on "Delete" "link" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' forumpost ')][contains(., 'Student post subject')]" "xpath_element"
+    And I press "Continue"
+    And I wait "4" seconds
+    Then I should not see "Student post subject"
+    And I should not see "Student post message"
+
+  @javascript
+  Scenario: A teacher can edit another user's posts
+    Given I log out
+    And I log in as "teacher1"
+    When I follow "Course 1"
+    And I follow "Test forum name"
+    And I follow "Teacher post subject"
+    And I click on "Edit" "link" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' forumpost ')][contains(., 'Student post subject')]" "xpath_element"
+    And I fill the moodle form with:
+      | Subject | Edited student subject |
+    And I press "Save changes"
+    And I wait "4" seconds
+    Then I should see "Edited student subject"
+    And I should see "Edited by Teacher 1 - original submission"
+
+  @javascript
+  Scenario: A student can't edit or delete another user's posts
+    When I follow "Teacher post subject"
+    Then I should not see "Edit" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' forumpost ')][contains(., 'Teacher post subject')]" "xpath_element"
+    And I should not see "Delete" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' forumpost ')][contains(., 'Teacher post subject')]" "xpath_element"
diff --git a/mod/forum/tests/behat/single_forum_discussion.feature b/mod/forum/tests/behat/single_forum_discussion.feature
new file mode 100644 (file)
index 0000000..9bf6526
--- /dev/null
@@ -0,0 +1,43 @@
+@mod_forum
+Feature: Single simple forum discussion type
+  In order to restrict the discussion topic to one
+  As a moodle teacher
+  I need to create a forum with a single simple discussion
+
+  Background:
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+      | student1 | Student | 1 | student1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "forum" to section "1" and I fill the form with:
+      | Forum name | Single discussion forum name |
+      | Forum type | A single simple discussion |
+      | Description | Single discussion forum description |
+
+  @javascript
+  Scenario: Teacher can start the single simple discussion
+    When I follow "Single discussion forum name"
+    Then I should see "Single discussion forum description" in the "div.firstpost.starter" "css_element"
+    And I should not see "Add a new discussion topic"
+
+  @javascript
+  Scenario: Student can not add more discussions
+    Given I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    When I reply "Single discussion forum name" post from "Single discussion forum name" forum with:
+      | Subject | Reply to single discussion subject |
+      | Message | Reply to single discussion message |
+    Then I should not see "Add a new discussion topic"
+    And I should see "Reply" in the "div.firstpost.starter" "css_element"
+    And I should see "Reply to single discussion message"
diff --git a/mod/forum/tests/behat/track_read_posts.feature b/mod/forum/tests/behat/track_read_posts.feature
new file mode 100644 (file)
index 0000000..0e8fe0d
--- /dev/null
@@ -0,0 +1,85 @@
+@mod_forum
+Feature: A teacher can set one of 3 possible options for tracking read forum posts
+  In order to ease the forum posts follow up
+  As a moodle user
+  I need to distinct the unread posts from the read ones
+
+  Background:
+    Given the following "users" exists:
+      | username | firstname | lastname | email | trackforums |
+      | student1 | Student | 1 | student1@asd.com | 1 |
+    And the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | student1 | C1 | student |
+    And I log in as "admin"
+    And I follow "Course 1"
+    And I turn editing mode on
+
+  @javascript
+  Scenario: Tracking forum posts on
+    Given I add a "forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Forum type | Standard forum for general use |
+      | Description | Test forum description |
+      | Read tracking for this forum | On |
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Test post subject |
+      | Message | Test post message |
+    And I wait "6" seconds
+    And I log out
+    When I log in as "student1"
+    And I follow "Course 1"
+    Then I should see "1 unread post"
+    And I follow "1 unread post"
+    And I should not see "Don't track unread posts"
+    And I follow "Test post subject"
+    And I follow "Course 1"
+    And I should not see "1 unread post"
+
+  @javascript
+  Scenario: Tracking forum posts off
+    Given I add a "forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Forum type | Standard forum for general use |
+      | Description | Test forum description |
+      | Read tracking for this forum | Off |
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Test post subject |
+      | Message | Test post message |
+    And I wait "6" seconds
+    And I log out
+    When I log in as "student1"
+    And I follow "Course 1"
+    Then I should not see "1 unread post"
+    And I follow "Test forum name"
+    And I should not see "Track unread posts"
+
+  @javascript
+  Scenario: Tracking forum posts optional
+    Given I add a "forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Forum type | Standard forum for general use |
+      | Description | Test forum description |
+      | Read tracking for this forum | Optional |
+    And I add a new discussion to "Test forum name" forum with:
+      | Subject | Test post subject |
+      | Message | Test post message |
+    And I wait "6" seconds
+    And I log out
+    When I log in as "student1"
+    And I follow "Course 1"
+    Then I should see "1 unread post"
+    And I follow "Test forum name"
+    And I follow "Don't track unread posts"
+    And I wait "4" seconds
+    And I follow "Course 1"
+    And I should not see "1 unread post"
+    And I follow "Test forum name"
+    And I follow "Track unread posts"
+    And I wait "4" seconds
+    And I follow "1"
+    And I follow "Course 1"
+    And I should not see "1 unread post"
index d3f350f..6ed12fe 100644 (file)
@@ -87,6 +87,14 @@ if ($accessmanager->is_preflight_check_required($attemptobj->get_attemptid())) {
     redirect($attemptobj->start_attempt_url(null, $page));
 }
 
+// Set up auto-save if required.
+$autosaveperiod = get_config('quiz', 'autosaveperiod');
+if ($autosaveperiod) {
+    $PAGE->requires->yui_module('moodle-mod_quiz-autosave',
+            'M.mod_quiz.autosave.init', array($autosaveperiod));
+}
+
+// Log this page view.
 add_to_log($attemptobj->get_courseid(), 'quiz', 'continue attempt',
         'review.php?attempt=' . $attemptobj->get_attemptid(),
         $attemptobj->get_quizid(), $attemptobj->get_cmid());
index eef2f89..0585941 100644 (file)
@@ -1358,6 +1358,23 @@ class quiz_attempt {
         $transaction->allow_commit();
     }
 
+    /**
+     * Process all the autosaved data that was part of the current request.
+     *
+     * @param int $timestamp the timestamp that should be stored as the modifed
+     * time in the database for these actions. If null, will use the current time.
+     */
+    public function process_auto_save($timestamp) {
+        global $DB;
+
+        $transaction = $DB->start_delegated_transaction();
+
+        $this->quba->process_all_autosaves($timestamp);
+        question_engine::save_questions_usage_by_activity($this->quba);
+
+        $transaction->allow_commit();
+    }
+
     /**
      * Update the flagged state for all question_attempts in this usage, if their
      * flagged state was changed in the request.
diff --git a/mod/quiz/autosave.ajax.php b/mod/quiz/autosave.ajax.php
new file mode 100644 (file)
index 0000000..9b6f132
--- /dev/null
@@ -0,0 +1,62 @@
+<?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/>.
+
+/**
+ * Thisscript processes ajax auto-save requests during the quiz.
+ *
+ * @package    mod_quiz
+ * @copyright  2013 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('AJAX_SCRIPT', true);
+
+require_once(dirname(__FILE__) . '/../../config.php');
+require_once($CFG->dirroot . '/mod/quiz/locallib.php');
+
+// Remember the current time as the time any responses were submitted
+// (so as to make sure students don't get penalized for slow processing on this page).
+$timenow = time();
+require_sesskey();
+
+// Get submitted parameters.
+$attemptid = required_param('attempt',  PARAM_INT);
+$thispage  = optional_param('thispage', 0, PARAM_INT);
+
+$transaction = $DB->start_delegated_transaction();
+$attemptobj = quiz_attempt::create($attemptid);
+
+// Check login.
+require_login($attemptobj->get_course(), false, $attemptobj->get_cm());
+
+// Check that this attempt belongs to this user.
+if ($attemptobj->get_userid() != $USER->id) {
+    throw new moodle_quiz_exception($attemptobj->get_quizobj(), 'notyourattempt');
+}
+
+// Check capabilities.
+if (!$attemptobj->is_preview_user()) {
+    $attemptobj->require_capability('mod/quiz:attempt');
+}
+
+// If the attempt is already closed, send them to the review page.
+if ($attemptobj->is_finished()) {
+    throw new moodle_quiz_exception($attemptobj->get_quizobj(),
+            'attemptalreadyclosed', null, $attemptobj->review_url());
+}
+
+$attemptobj->process_auto_save($timenow);
+$transaction->allow_commit();
index 47dabba..1c1ccf5 100644 (file)
@@ -97,6 +97,8 @@ $string['attemptsonly'] = 'Show only students with attempts';
 $string['attemptstate'] = 'State';
 $string['attemptstillinprogress'] = 'Attempt still in progress';
 $string['attemptsunlimited'] = 'Unlimited attempts';
+$string['autosaveperiod'] = 'Auto-save period';
+$string['autosaveperiod_desc'] = 'If you turn on this option, then the students will be automatically saved during quiz attempts. The trade-off here is that doing this will increase the load on your server, but it makes it much less likely that students will lose their work.';
 $string['back'] = 'Back to preview question';
 $string['backtocourse'] = 'Back to the course';
 $string['backtoquestionlist'] = 'Back to question list';
@@ -231,6 +233,7 @@ $string['deletingquestionattempts'] = 'Deleting question attempts';
 $string['description'] = 'Description';
 $string['disabled'] = 'Disabled';
 $string['displayoptions'] = 'Display options';
+$string['donotuseautosave'] = 'Do not use auto-save';
 $string['download'] = 'Click to download the exported category file';
 $string['downloadextra'] = '(file is also stored in the course files in the /backupdata/quiz folder)';
 $string['duplicateresponse'] = 'This submission has been ignored because you gave an equivalent answer earlier.';
@@ -504,6 +507,7 @@ $string['numattemptsmade'] = '{$a} attempts made on this quiz';
 $string['numberabbr'] = '#';
 $string['numerical'] = 'Numerical';
 $string['numquestionsx'] = 'Questions: {$a}';
+$string['oneminute'] = '1 minute';
 $string['onlyteachersexport'] = 'Only teachers can export questions';
 $string['onlyteachersimport'] = 'Only teachers with editing rights can import questions';
 $string['onthispage'] = 'This page';
index 8cb0854..ec07c63 100644 (file)
@@ -200,6 +200,16 @@ if (!empty($CFG->enableoutcomes)) {
         '0'));
 }
 
+// Autosave frequency.
+$options = array(
+      0 => get_string('donotuseautosave', 'quiz'),
+     60 => get_string('oneminute', 'quiz'),
+    120 => get_string('numminutes', 'moodle', 2),
+    300 => get_string('numminutes', 'moodle', 5),
+);
+$quizsettings->add(new admin_setting_configselect('quiz/autosaveperiod',
+        get_string('autosaveperiod', 'quiz'), get_string('autosaveperiod_desc', 'quiz'), 0, $options));
+
 // Now, depending on whether any reports have their own settings page, add
 // the quiz setting page to the appropriate place in the tree.
 if (empty($reportsbyname)) {
diff --git a/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-coverage.js b/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-coverage.js
new file mode 100644 (file)
index 0000000..983a753
Binary files /dev/null and b/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-coverage.js differ
diff --git a/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-debug.js b/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-debug.js
new file mode 100644 (file)
index 0000000..7d54780
Binary files /dev/null and b/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-debug.js differ
diff --git a/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-min.js b/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-min.js
new file mode 100644 (file)
index 0000000..005bc0a
Binary files /dev/null and b/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave-min.js differ
diff --git a/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave.js b/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave.js
new file mode 100644 (file)
index 0000000..44eb073
Binary files /dev/null and b/mod/quiz/yui/build/moodle-mod_quiz-autosave/moodle-mod_quiz-autosave.js differ
diff --git a/mod/quiz/yui/src/autosave/build.json b/mod/quiz/yui/src/autosave/build.json
new file mode 100644 (file)
index 0000000..31f970c
--- /dev/null
@@ -0,0 +1,10 @@
+{
+    "name": "moodle-mod_quiz-autosave",
+    "builds": {
+        "moodle-mod_quiz-autosave": {
+            "jsfiles": [
+                "autosave.js"
+            ]
+        }
+    }
+}
diff --git a/mod/quiz/yui/src/autosave/js/autosave.js b/mod/quiz/yui/src/autosave/js/autosave.js
new file mode 100644 (file)
index 0000000..14c039c
--- /dev/null
@@ -0,0 +1,224 @@
+// 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/>.
+
+
+/**
+ * Auto-save functionality for during quiz attempts.
+ *
+ * @package   mod_quiz
+ * @copyright 1999 onwards Martin Dougiamas  {@link http://moodle.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+M.mod_quiz = M.mod_quiz || {};
+M.mod_quiz.autosave = {
+    /** Delays and repeat counts. */
+    TINYMCE_DETECTION_DELAY:  500,
+    TINYMCE_DETECTION_REPEATS: 20,
+    WATCH_HIDDEN_DELAY:      1000,
+
+    /** Selectors. */
+    SELECTORS: {
+        QUIZ_FORM:             '#responseform',
+        VALUE_CHANGE_ELEMENTS: 'input, textarea',
+        CHANGE_ELEMENTS:       'input, select',
+        HIDDEN_INPUTS:         'input[type=hidden]'
+    },
+
+    /** Script that handles the auto-saves. */
+    AUTOSAVE_HANDLER: M.cfg.wwwroot + '/mod/quiz/autosave.ajax.php',
+
+    /** The delay between a change being made, and it being auto-saved. */
+    delay: 120000,
+
+    /** The form we are monitoring. */
+    form: null,
+
+    /** Whether the form has been modified since the last save started. */
+    dirty: false,
+
+    /** Timer object for the delay between form modifaction and the save starting. */
+    delay_timer: null,
+
+    /** Y.io transaction for the save ajax request. */
+    save_transaction: null,
+
+    /** Properly bound key change handler. */
+    editor_change_handler: null,
+
+    hidden_field_values: {},
+
+    /**
+     * Initialise the autosave code.
+     * @param delay the delay, in seconds, between a change being detected, and
+     * a save happening.
+     */
+    init: function(delay) {
+        this.form = Y.one(this.SELECTORS.QUIZ_FORM);
+        if (!this.form) {
+            Y.log('No response form found. Why did you try to set up autosave?');
+            return;
+        }
+
+        this.delay = delay * 1000;
+
+        this.form.delegate('valuechange', this.value_changed, this.SELECTORS.VALUE_CHANGE_ELEMENTS, this);
+        this.form.delegate('change',      this.value_changed, this.SELECTORS.CHANGE_ELEMENTS,       this);
+        this.form.on('submit', this.stop_autosaving, this);
+
+        this.init_tinymce(this.TINYMCE_DETECTION_REPEATS);
+
+        this.save_hidden_field_values();
+        this.watch_hidden_fields();
+    },
+
+    save_hidden_field_values: function() {
+        this.form.all(this.SELECTORS.HIDDEN_INPUTS).each(function(hidden) {
+            var name  = hidden.get('name');
+            if (!name) {
+                return;
+            }
+            this.hidden_field_values[name] = hidden.get('value');
+        }, this);
+    },
+
+    watch_hidden_fields: function() {
+        this.detect_hidden_field_changes();
+        Y.later(this.WATCH_HIDDEN_DELAY, this, this.watch_hidden_fields);
+    },
+
+    detect_hidden_field_changes: function() {
+        this.form.all(this.SELECTORS.HIDDEN_INPUTS).each(function(hidden) {
+            var name  = hidden.get('name'),
+                value = hidden.get('value');
+            if (!name) {
+                return;
+            }
+            if (!(name in this.hidden_field_values) || value !== this.hidden_field_values[name]) {
+                this.hidden_field_values[name] = value;
+                this.value_changed({target: hidden});
+            }
+        }, this);
+    },
+
+    /**
+     * @param repeatcount Because TinyMCE might load slowly, after us, we need
+     * to keep trying every 10 seconds or so, until we detect TinyMCE is there,
+     * or enough time has passed.
+     */
+    init_tinymce: function(repeatcount) {
+        if (typeof tinymce === 'undefined') {
+            if (repeatcount > 0) {
+                Y.later(this.TINYMCE_DETECTION_DELAY, this, self.init_tinymce,repeatcount - 1);
+            }
+            return;
+        }
+
+        Y.log('Found TinyMCE.');
+        this.editor_change_handler = Y.bind(this.editor_changed, this);
+        tinyMCE.onAddEditor.add(Y.bind(this.init_tinymce_editor, this));
+    },
+
+    /**
+     * @param repeatcount Because TinyMCE might load slowly, after us, we need
+     * to keep trying every 10 seconds or so, until we detect TinyMCE is there,
+     * or enough time has passed.
+     */
+    init_tinymce_editor: function(notused, editor) {
+        Y.log('Found TinyMCE editor ' + editor.id + '.');
+        editor.onChange.add(this.editor_change_handler);
+        editor.onRedo.add(this.editor_change_handler);
+        editor.onUndo.add(this.editor_change_handler);
+        editor.onKeyDown.add(this.editor_change_handler);
+    },
+
+    value_changed: function(e) {
+        if (e.target.get('name') === 'thispage') {
+            return; // Not interesting.
+        }
+        Y.log('Detected a value change in element ' + e.target.get('name') + '.');
+        this.start_save_timer_if_necessary();
+    },
+
+    editor_changed: function(editor) {
+        Y.log('Detected a value change in editor ' + editor.id + '.');
+        this.start_save_timer_if_necessary();
+    },
+
+    start_save_timer_if_necessary: function() {
+        this.dirty = true;
+
+        if (this.delay_timer || this.save_transaction) {
+            // Already counting down or daving.
+            return;
+        }
+
+        this.start_save_timer();
+    },
+
+    start_save_timer: function() {
+        this.cancel_delay();
+        this.delay_timer = Y.later(this.delay, this, this.save_changes);
+    },
+
+    cancel_delay: function() {
+        if (this.delay_timer) {
+            this.delay_timer.cancel();
+        }
+        this.delay_timer = null;
+    },
+
+    save_changes: function() {
+        this.cancel_delay();
+        this.dirty = false;
+
+        if (this.is_time_nearly_over()) {
+            Y.log('No more saving, time is nearly over.');
+            this.stop_autosaving();
+            return;
+        }
+
+        Y.log('Doing a save.');
+        this.save_transaction = Y.io(this.AUTOSAVE_HANDLER, {
+            method:  'POST',
+            form:    {id: this.form},
+            on:      {complete: this.save_done},
+            context: this
+        });
+    },
+
+    save_done: function() {
+        Y.log('Save completed.');
+        this.save_transaction = null;
+
+        if (this.dirty) {
+            Y.log('Dirty after save.');
+            this.start_save_timer();
+        }
+    },
+
+    is_time_nearly_over: function() {
+        return M.mod_quiz.timer && M.mod_quiz.timer.endtime &&
+                (new Date().getTime() + 2*this.delay) > M.mod_quiz.timer.endtime;
+    },
+
+    stop_autosaving: function() {
+        this.cancel_delay();
+        this.delay_timer = true;
+        if (this.save_transaction) {
+            this.save_transaction.abort();
+        }
+    }
+};
diff --git a/mod/quiz/yui/src/autosave/meta/autosave.json b/mod/quiz/yui/src/autosave/meta/autosave.json
new file mode 100644 (file)
index 0000000..0b265c5
--- /dev/null
@@ -0,0 +1,12 @@
+{
+    "moodle-mod_quiz-autosave": {
+        "requires": [
+            "base",
+            "node",
+            "event",
+            "event-valuechange",
+            "node-event-delegate",
+            "io-form"
+        ]
+    }
+}
diff --git a/mod/survey/tests/behat/survey_types.feature b/mod/survey/tests/behat/survey_types.feature
new file mode 100644 (file)
index 0000000..60d302a
--- /dev/null
@@ -0,0 +1,36 @@
+@mod_survey
+Feature: A teacher can set three types of survey activity
+  In order to use verified survey instruments
+  As a moodle teacher
+  I need to set survey activities and select which survey type suits my needs
+
+  Scenario: Switching between the three survey types
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    When I add a "Survey" to section "1" and I fill the form with:
+      | Name | Test survey name |
+      | Survey type | ATTLS (20 item version) |
+      | Description | Test survey description |
+    And I follow "Test survey name"
+    Then I should see "Attitudes Towards Thinking and Learning"
+    And I follow "Edit settings"
+    And I fill the moodle form with:
+      | Survey type | Critical incidents |
+    And I press "Save and display"
+    And I should see "At what moment in class were you most engaged as a learner?"
+    And I follow "Edit settings"
+    And I fill the moodle form with:
+      | Survey type | COLLES (Preferred and Actual) |
+    And I press "Save and display"
+    And I should see "In this online unit..."
+    And I should see "my learning focuses on issues that interest me."
index bd122dc..4219cba 100644 (file)
@@ -435,6 +435,19 @@ abstract class question_behaviour {
      */
     public abstract function process_action(question_attempt_pending_step $pendingstep);
 
+    /**
+     * Auto-saved data. By default this does nothing. interesting processing is
+     * done in {@link question_behaviour_with_save}.
+     *
+     * @param question_attempt_pending_step $pendingstep a partially initialised step
+     *      containing all the information about the action that is being peformed. This
+     *      information can be accessed using {@link question_attempt_step::get_behaviour_var()}.
+     * @return bool either {@link question_attempt::KEEP} or {@link question_attempt::DISCARD}
+     */
+    public function process_autosave(question_attempt_pending_step $pendingstep) {
+        return question_attempt::DISCARD;
+    }
+
     /**
      * Implementation of processing a manual comment/grade action that should
      * be suitable for most subclasses.
@@ -570,6 +583,29 @@ abstract class question_behaviour_with_save extends question_behaviour {
         return $this->question->is_complete_response($pendingstep->get_qt_data());
     }
 
+    public function process_autosave(question_attempt_pending_step $pendingstep) {
+        // If already finished. Nothing to do.
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        }
+
+        // If the new data is the same as we already have, then we don't need it.
+        if ($this->is_same_response($pendingstep)) {
+            return question_attempt::DISCARD;
+        }
+
+        // Repeat that test discarding any existing autosaved data.
+        if ($this->qa->has_autosaved_step()) {
+            $this->qa->discard_autosaved_step();
+            if ($this->is_same_response($pendingstep)) {
+                return question_attempt::DISCARD;
+            }
+        }
+
+        // OK, we need to save.
+        return $this->process_save($pendingstep);
+    }
+
     /**
      * Implementation of processing a save action that should be suitable for
      * most subclasses.
index 77c5d52..b58c05e 100644 (file)
@@ -63,7 +63,7 @@ class question_engine_data_mapper {
     /**
      * @param moodle_database $db a database connectoin. Defaults to global $DB.
      */
-    public function __construct($db = null) {
+    public function __construct(moodle_database $db = null) {
         if (is_null($db)) {
             global $DB;
             $this->db = $DB;
@@ -613,12 +613,10 @@ ORDER BY qa.slot
      * @return array of question_attempts.
      */
     public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
-        global $DB;
-
         $params = $qubaids->from_where_params();
         $params['questionid'] = $questionid;
 
-        $records = $DB->get_recordset_sql("
+        $records = $this->db->get_recordset_sql("
 SELECT
     quba.contextid,
     quba.preferredbehaviour,
index 9fdfc03..df13890 100644 (file)
@@ -71,10 +71,11 @@ abstract class question_engine {
     /**
      * Load a {@link question_usage_by_activity} from the database, based on its id.
      * @param int $qubaid the id of the usage to load.
+     * @param moodle_database $db a database connectoin. Defaults to global $DB.
      * @return question_usage_by_activity loaded from the database.
      */
-    public static function load_questions_usage_by_activity($qubaid) {
-        $dm = new question_engine_data_mapper();
+    public static function load_questions_usage_by_activity($qubaid, moodle_database $db = null) {
+        $dm = new question_engine_data_mapper($db);
         return $dm->load_questions_usage_by_activity($qubaid);
     }
 
@@ -83,9 +84,10 @@ abstract class question_engine {
      * if the usage was newly created by {@link make_questions_usage_by_activity()}
      * or loaded from the database using {@link load_questions_usage_by_activity()}
      * @param question_usage_by_activity the usage to save.
+     * @param moodle_database $db a database connectoin. Defaults to global $DB.
      */
-    public static function save_questions_usage_by_activity(question_usage_by_activity $quba) {
-        $dm = new question_engine_data_mapper();
+    public static function save_questions_usage_by_activity(question_usage_by_activity $quba, moodle_database $db = null) {
+        $dm = new question_engine_data_mapper($db);
         $observer = $quba->get_observer();
         if ($observer instanceof question_engine_unit_of_work) {
             $observer->save($dm);
index 68211ed..ae4be38 100644 (file)
@@ -118,6 +118,12 @@ class question_attempt {
     /** @var array of {@link question_attempt_step}s. The steps in this attempt. */
     protected $steps = array();
 
+    /**
+     * @var question_attempt_step if, when we loaded the step from the DB, there was
+     * an autosaved step, we save a pointer to it here. (It is also added to the $steps array.)
+     */
+    protected $autosavedstep = null;
+
     /** @var boolean whether the user has flagged this attempt within the usage. */
     protected $flagged = false;
 
@@ -362,6 +368,14 @@ class question_attempt {
         return end($this->steps);
     }
 
+    /**
+     * @return boolean whether this question_attempt has autosaved data from
+     * some time in the past.
+     */
+    public function has_autosaved_step() {
+        return !is_null($this->autosavedstep);
+    }
+
     /**
      * @return question_attempt_step_iterator for iterating over the steps in
      * this attempt, in order.
@@ -788,6 +802,31 @@ class question_attempt {
         $this->observer->notify_step_added($step, $this, key($this->steps));
     }
 
+    /**
+     * Add an auto-saved step to this question attempt. We mark auto-saved steps by
+     * changing saving the step number with a - sign.
+     * @param question_attempt_step $step the new step.
+     */
+    protected function add_autosaved_step(question_attempt_step $step) {
+        $this->steps[] = $step;
+        $this->autosavedstep = $step;
+        end($this->steps);
+        $this->observer->notify_step_added($step, $this, -key($this->steps));
+    }
+
+    /**
+     * Discard any auto-saved data belonging to this question attempt.
+     */
+    public function discard_autosaved_step() {
+        if (!$this->has_autosaved_step()) {
+            return;
+        }
+
+        $autosaved = array_pop($this->steps);
+        $this->autosavedstep = null;
+        $this->observer->notify_step_deleted($autosaved, $this);
+    }
+
     /**
      * Use a strategy to pick a variant.
      * @param question_variant_selection_strategy $variantstrategy a strategy.
@@ -1045,10 +1084,12 @@ class question_attempt {
      * Perform the action described by $submitteddata.
      * @param array $submitteddata the submitted data the determines the action.
      * @param int $timestamp the time to record for the action. (If not given, use now.)
-     * @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
+     * @param int $userid the user to attribute the action to. (If not given, use the current user.)
+     * @param int $existingstepid used by the regrade code.
      */
     public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) {
         $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid, $existingstepid);
+        $this->discard_autosaved_step();
         if ($this->behaviour->process_action($pendingstep) == self::KEEP) {
             $this->add_step($pendingstep);
             if ($pendingstep->response_summary_changed()) {
@@ -1057,6 +1098,22 @@ class question_attempt {
         }
     }
 
+    /**
+     * Process an autosave.
+     * @param array $submitteddata the submitted data the determines the action.
+     * @param int $timestamp the time to record for the action. (If not given, use now.)
+     * @param int $userid the user to attribute the action to. (If not given, use the current user.)
+     * @return bool whether anything was saved.
+     */
+    public function process_autosave($submitteddata, $timestamp = null, $userid = null) {
+        $pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid);
+        if ($this->behaviour->process_autosave($pendingstep) == self::KEEP) {
+            $this->add_autosaved_step($pendingstep);
+            return true;
+        }
+        return false;
+    }
+
     /**
      * Perform a finish action on this question attempt. This corresponds to an
      * external finish action, for example the user pressing Submit all and finish
@@ -1212,6 +1269,7 @@ class question_attempt {
 
         $qa->behaviour = question_engine::make_behaviour(
                 $record->behaviour, $qa, $preferredbehaviour);
+        $qa->observer = $observer;
 
         // If attemptstepid is null (which should not happen, but has happened
         // due to corrupt data, see MDL-34251) then the current pointer in $records
@@ -1223,12 +1281,28 @@ class question_attempt {
         }
 
         $i = 0;
+        $autosavedstep = null;
+        $autosavedsequencenumber = null;
         while ($record && $record->questionattemptid == $questionattemptid && !is_null($record->attemptstepid)) {
-            $qa->steps[$i] = question_attempt_step::load_from_records($records, $record->attemptstepid);
-            if ($i == 0) {
-                $question->apply_attempt_state($qa->steps[0]);
+            $sequencenumber = $record->sequencenumber;
+            $nextstep = question_attempt_step::load_from_records($records, $record->attemptstepid);
+
+            if ($sequencenumber < 0) {
+                if (!$autosavedstep) {
+                    $autosavedstep = $nextstep;
+                    $autosavedsequencenumber = -$sequencenumber;
+                } else {
+                    // Old redundant data. Mark it for deletion.
+                    $qa->observer->notify_step_deleted($nextstep, $qa);
+                }
+            } else {
+                $qa->steps[$i] = $nextstep;
+                if ($i == 0) {
+                    $question->apply_attempt_state($qa->steps[0]);
+                }
+                $i++;
             }
-            $i++;
+
             if ($records->valid()) {
                 $record = $records->current();
             } else {
@@ -1236,7 +1310,14 @@ class question_attempt {
             }
         }
 
-        $qa->observer = $observer;
+        if ($autosavedstep) {
+            if ($autosavedsequencenumber >= $i) {
+                $qa->autosavedstep = $autosavedstep;
+                $qa->steps[$i] = $qa->autosavedstep;
+            } else {
+                $qa->observer->notify_step_deleted($autosavedstep, $qa);
+            }
+        }
 
         return $qa;
     }
index 9fa6e0c..77541e2 100644 (file)
@@ -511,7 +511,49 @@ class question_usage_by_activity {
      * instead of the data from $_POST.
      */
     public function process_all_actions($timestamp = null, $postdata = null) {
-        // note: we must not use "question_attempt::get_submitted_var()" because there is no attempt instance!!!
+        foreach ($this->get_slots_in_request($postdata) as $slot) {
+            if (!$this->validate_sequence_number($slot, $postdata)) {
+                continue;
+            }
+            $submitteddata = $this->extract_responses($slot, $postdata);
+            $this->process_action($slot, $submitteddata, $timestamp);
+        }
+        $this->update_question_flags($postdata);
+    }
+
+    /**
+     * Process all the question autosave data in the current request.
+     *
+     * If there is a parameter slots included in the post data, then only
+     * those question numbers will be processed, otherwise all questions in this
+     * useage will be.
+     *
+     * This function also does {@link update_question_flags()}.
+     *
+     * @param int $timestamp optional, use this timestamp as 'now'.
+     * @param array $postdata optional, only intended for testing. Use this data
+     * instead of the data from $_POST.
+     */
+    public function process_all_autosaves($timestamp = null, $postdata = null) {
+        foreach ($this->get_slots_in_request($postdata) as $slot) {
+            if (!$this->is_autosave_required($slot, $postdata)) {
+                continue;
+            }
+            $submitteddata = $this->extract_responses($slot, $postdata);
+            $this->process_autosave($slot, $submitteddata, $timestamp);
+        }
+        $this->update_question_flags($postdata);
+    }
+
+    /**
+     * Get the list of slot numbers that should be processed as part of processing
+     * the current request.
+     * @param array $postdata optional, only intended for testing. Use this data
+     * instead of the data from $_POST.
+     * @return array of slot numbers.
+     */
+    protected function get_slots_in_request($postdata = null) {
+        // Note: we must not use "question_attempt::get_submitted_var()" because there is no attempt instance!!!
         if (is_null($postdata)) {
             $slots = optional_param('slots', null, PARAM_SEQUENCE);
         } else if (array_key_exists('slots', $postdata)) {
@@ -526,14 +568,7 @@ class question_usage_by_activity {
         } else {
             $slots = explode(',', $slots);
         }
-        foreach ($slots as $slot) {
-            if (!$this->validate_sequence_number($slot, $postdata)) {
-                continue;
-            }
-            $submitteddata = $this->extract_responses($slot, $postdata);
-            $this->process_action($slot, $submitteddata, $timestamp);
-        }
-        $this->update_question_flags($postdata);
+        return $slots;
     }
 
     /**
@@ -560,6 +595,18 @@ class question_usage_by_activity {
         $this->observer->notify_attempt_modified($qa);
     }
 
+    /**
+     * Process an autosave action on a specific question.
+     * @param int $slot the number used to identify this question within this usage.
+     * @param $submitteddata the submitted data that constitutes the action.
+     */
+    public function process_autosave($slot, $submitteddata, $timestamp = null) {
+        $qa = $this->get_question_attempt($slot);
+        if ($qa->process_autosave($submitteddata, $timestamp)) {
+            $this->observer->notify_attempt_modified($qa);
+        }
+    }
+
     /**
      * Check that the sequence number, that detects weird things like the student
      * clicking back, is OK. If the sequence check variable is not present, returns
index db9c02d..be6dd29 100644 (file)
@@ -49,6 +49,40 @@ class testable_question_attempt extends question_attempt {
 }
 
 
+/**
+ * Test subclass to allow access to some protected data so that the correct
+ * behaviour can be verified.
+ *
+ * @copyright  2012 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class testable_question_engine_unit_of_work extends question_engine_unit_of_work {
+    public function get_modified() {
+        return $this->modified;
+    }
+
+    public function get_attempts_added() {
+        return $this->attemptsadded;
+    }
+
+    public function get_attempts_modified() {
+        return $this->attemptsmodified;
+    }
+
+    public function get_steps_added() {
+        return $this->stepsadded;
+    }
+
+    public function get_steps_modified() {
+        return $this->stepsmodified;
+    }
+
+    public function get_steps_deleted() {
+        return $this->stepsdeleted;
+    }
+}
+
+
 /**
  * Base class for question type test helpers.
  *
@@ -649,14 +683,50 @@ abstract class qbehaviour_walkthrough_test_base extends question_testcase {
         $this->slot = $this->quba->add_question($question, $maxmark);
         $this->quba->start_question($this->slot, $variant);
     }
+
     protected function process_submission($data) {
-        $this->quba->process_action($this->slot, $data);
+        // Backwards compatibility.
+        reset($data);
+        if (count($data) == 1 && key($data) === '-finish') {
+            $this->finish();
+        }
+
+        $prefix = $this->quba->get_field_prefix($this->slot);
+        $fulldata = array(
+            'slots' => $this->slot,
+            $prefix . ':sequencecheck' => $this->get_question_attempt()->get_num_steps(),
+        );
+        foreach ($data as $name => $value) {
+            $fulldata[$prefix . $name] = $value;
+        }
+        $this->quba->process_all_actions(time(), $fulldata);
+    }
+
+    protected function process_autosave($data) {
+        $this->quba->process_autosave($this->slot, $data);
+    }
+
+    protected function finish() {
+        $this->quba->finish_all_questions();
     }
 
     protected function manual_grade($comment, $mark, $commentformat = null) {
         $this->quba->manual_grade($this->slot, $comment, $mark, $commentformat);
     }
 
+    protected function save_quba(moodle_database $db = null) {
+        question_engine::save_questions_usage_by_activity($this->quba, $db);
+    }
+
+    protected function load_quba(moodle_database $db = null) {
+        $this->quba = question_engine::load_questions_usage_by_activity($this->quba->get_id(), $db);
+    }
+
+    protected function delete_quba() {
+        question_engine::delete_questions_usage_by_activity($this->quba->get_id());
+        $this->quba = null;
+    }
+
     protected function check_current_state($state) {
         $this->assertEquals($state, $this->quba->get_question_state($this->slot),
             'Questions is in the wrong state.');
@@ -684,6 +754,36 @@ abstract class qbehaviour_walkthrough_test_base extends question_testcase {
         $this->currentoutput = $this->quba->render_question($this->slot, $this->displayoptions);
     }
 
+    protected function check_output_contains_text_input($name, $value = null, $enabled = true) {
+        $attributes = array(
+            'type' => 'text',
+            'name' => $this->quba->get_field_prefix($this->slot) . $name,
+        );
+        if (!is_null($value)) {
+            $attributes['value'] = $value;
+        }
+        if (!$enabled) {
+            $attributes['readonly'] = 'readonly';
+        }
+        $matcher = $this->get_tag_matcher('input', $attributes);
+        $this->assertTag($matcher, $this->currentoutput,
+                'Looking for an input with attributes ' . html_writer::attributes($attributes) . ' in ' . $this->currentoutput);
+
+        if ($enabled) {
+            $matcher['attributes']['readonly'] = 'readonly';
+            $this->assertNotTag($matcher, $this->currentoutput,
+                    'input with attributes ' . html_writer::attributes($attributes) .
+                    ' should not be read-only in ' . $this->currentoutput);
+        }
+    }
+
+    protected function get_tag_matcher($tag, $attributes) {
+        return array(
+            'tag' => $tag,
+            'attributes' => $attributes,
+        );
+    }
+
     /**
      * @param $condition one or more Expectations. (users varargs).
      */
diff --git a/question/engine/tests/questionattempt_db_test.php b/question/engine/tests/questionattempt_db_test.php
new file mode 100644 (file)
index 0000000..1f9f970
--- /dev/null
@@ -0,0 +1,255 @@
+<?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/>.
+
+/**
+ * This file contains tests for the question_attempt class.
+ *
+ * Action methods like start, process_action and finish are assumed to be
+ * tested by walkthrough tests in the various behaviours.
+ *
+ * @package    moodlecore
+ * @subpackage questionengine
+ * @copyright  2009 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once(dirname(__FILE__) . '/../lib.php');
+require_once(dirname(__FILE__) . '/helpers.php');
+
+
+/**
+ * Unit tests for loading data into the {@link question_attempt} class.
+ *
+ * @copyright  2009 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_attempt_db_test extends data_loading_method_test_base {
+    public function test_load() {
+        $records = new question_test_recordset(array(
+            array('questionattemptid', 'contextid', 'questionusageid', 'slot',
+                                   'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'flagged',
+                                                                                       'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
+                                                                                                               'attemptstepid', 'sequencenumber', 'state', 'fraction',
+                                                                                                                                                'timecreated', 'userid', 'name', 'value'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo',              null, 1256233700, 1,       null, null),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete',          null, 1256233705, 1,   'answer',  '1'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1, '', '', '', 1256233790, 3, 2, 'complete',          null, 1256233710, 1,   'answer',  '0'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 4, 3, 'complete',          null, 1256233715, 1,   'answer',  '1'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 5, 4, 'gradedright',  1.0000000, 1256233720, 1,  '-finish',  '1'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1, '-comment', 'Not good enough!'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1,    '-mark',  '1'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1, '-maxmark',  '2'),
+        ));
+
+        $question = test_question_maker::make_question('truefalse', 'true');
+        $question->id = -1;
+
+        question_bank::start_unit_test();
+        question_bank::load_test_question_data($question);
+        $qa = question_attempt::load_from_records($records, 1, new question_usage_null_observer(), 'deferredfeedback');
+        question_bank::end_unit_test();
+
+        $this->assertEquals($question->questiontext, $qa->get_question()->questiontext);
+
+        $this->assertEquals(6, $qa->get_num_steps());
+
+        $step = $qa->get_step(0);
+        $this->assertEquals(question_state::$todo, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233700, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array(), $step->get_all_data());
+
+        $step = $qa->get_step(1);
+        $this->assertEquals(question_state::$complete, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233705, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array('answer' => '1'), $step->get_all_data());
+
+        $step = $qa->get_step(2);
+        $this->assertEquals(question_state::$complete, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233710, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array('answer' => '0'), $step->get_all_data());
+
+        $step = $qa->get_step(3);
+        $this->assertEquals(question_state::$complete, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233715, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array('answer' => '1'), $step->get_all_data());
+
+        $step = $qa->get_step(4);
+        $this->assertEquals(question_state::$gradedright, $step->get_state());
+        $this->assertEquals(1, $step->get_fraction());
+        $this->assertEquals(1256233720, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array('-finish' => '1'), $step->get_all_data());
+
+        $step = $qa->get_step(5);
+        $this->assertEquals(question_state::$mangrpartial, $step->get_state());
+        $this->assertEquals(0.5, $step->get_fraction());
+        $this->assertEquals(1256233790, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array('-comment' => 'Not good enough!', '-mark' => '1', '-maxmark' => '2'),
+                $step->get_all_data());
+    }
+
+    public function test_load_missing_question() {
+        $records = new question_test_recordset(array(
+            array('questionattemptid', 'contextid', 'questionusageid', 'slot',
+                                   'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'flagged',
+                                                                                       'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
+                                                                                                               'attemptstepid', 'sequencenumber', 'state', 'fraction',
+                                                                                                                                                'timecreated', 'userid', 'name', 'value'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo',              null, 1256233700, 1,       null, null),
+        ));
+
+        question_bank::start_unit_test();
+        $qa = question_attempt::load_from_records($records, 1, new question_usage_null_observer(), 'deferredfeedback');
+        question_bank::end_unit_test();
+
+        $missingq = question_bank::get_qtype('missingtype')->make_deleted_instance(-1, 2);
+        $this->assertEquals($missingq, $qa->get_question());
+
+        $this->assertEquals(1, $qa->get_num_steps());
+
+        $step = $qa->get_step(0);
+        $this->assertEquals(question_state::$todo, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233700, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array(), $step->get_all_data());
+    }
+
+    public function test_load_with_autosaved_data() {
+        $records = new question_test_recordset(array(
+            array('questionattemptid', 'contextid', 'questionusageid', 'slot',
+                                   'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'flagged',
+                                                                                       'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
+                                                                                                             'attemptstepid', 'sequencenumber', 'state', 'fraction',
+                                                                                                                                                'timecreated', 'userid', 'name', 'value'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 4, -3, 'complete',          null, 1256233715, 1,   'answer',  '1'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1,  0, 'todo',              null, 1256233700, 1,       null, null),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 2,  1, 'complete',          null, 1256233705, 1,   'answer',  '1'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1, '', '', '', 1256233790, 3,  2, 'complete',          null, 1256233710, 1,   'answer',  '0'),
+        ));
+
+        $question = test_question_maker::make_question('truefalse', 'true');
+        $question->id = -1;
+
+        question_bank::start_unit_test();
+        question_bank::load_test_question_data($question);
+        $qa = question_attempt::load_from_records($records, 1, new question_usage_null_observer(), 'deferredfeedback');
+        question_bank::end_unit_test();
+
+        $this->assertEquals($question->questiontext, $qa->get_question()->questiontext);
+
+        $this->assertEquals(4, $qa->get_num_steps());
+        $this->assertTrue($qa->has_autosaved_step());
+
+        $step = $qa->get_step(0);
+        $this->assertEquals(question_state::$todo, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233700, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array(), $step->get_all_data());
+
+        $step = $qa->get_step(1);
+        $this->assertEquals(question_state::$complete, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233705, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array('answer' => '1'), $step->get_all_data());
+
+        $step = $qa->get_step(2);
+        $this->assertEquals(question_state::$complete, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233710, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array('answer' => '0'), $step->get_all_data());
+
+        $step = $qa->get_step(3);
+        $this->assertEquals(question_state::$complete, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233715, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array('answer' => '1'), $step->get_all_data());
+    }
+
+    public function test_load_with_unnecessary_autosaved_data() {
+        // The point here is that the somehow (probably due to two things
+        // happening concurrently, we have autosaved data in the database that
+        // has already been superceded by real data, so it should be ignored.
+        // There is also a second lot of redundant data to delete.
+        $records = new question_test_recordset(array(
+            array('questionattemptid', 'contextid', 'questionusageid', 'slot',
+                                   'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'flagged',
+                                                                                       'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
+                                                                                                             'attemptstepid', 'sequencenumber', 'state', 'fraction',
+                                                                                                                                                'timecreated', 'userid', 'name', 'value'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 5, -2, 'complete',          null, 1256233715, 1,   'answer',  '0'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 4, -1, 'complete',          null, 1256233715, 1,   'answer',  '0'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1,  0, 'todo',              null, 1256233700, 1,       null, null),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 2,  1, 'complete',          null, 1256233705, 1,   'answer',  '1'),
+            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1, '', '', '', 1256233790, 3,  2, 'complete',          null, 1256233710, 1,   'answer',  '0'),
+        ));
+
+        $question = test_question_maker::make_question('truefalse', 'true');
+        $question->id = -1;
+
+        question_bank::start_unit_test();
+        question_bank::load_test_question_data($question);
+        $observer = new testable_question_engine_unit_of_work(
+                question_engine::make_questions_usage_by_activity('unit_test', context_system::instance()));
+        $qa = question_attempt::load_from_records($records, 1, $observer, 'deferredfeedback');
+        question_bank::end_unit_test();
+
+        $this->assertEquals($question->questiontext, $qa->get_question()->questiontext);
+
+        $this->assertEquals(3, $qa->get_num_steps());
+        $this->assertFalse($qa->has_autosaved_step());
+
+        $step = $qa->get_step(0);
+        $this->assertEquals(question_state::$todo, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233700, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array(), $step->get_all_data());
+
+        $step = $qa->get_step(1);
+        $this->assertEquals(question_state::$complete, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233705, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array('answer' => '1'), $step->get_all_data());
+
+        $step = $qa->get_step(2);
+        $this->assertEquals(question_state::$complete, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEquals(1256233710, $step->get_timecreated());
+        $this->assertEquals(1, $step->get_user_id());
+        $this->assertEquals(array('answer' => '0'), $step->get_all_data());
+
+        $this->assertEquals(2, count($observer->get_steps_deleted()));
+    }
+}
index aaf2926..bb95d70 100644 (file)
@@ -139,235 +139,3 @@ class question_attempt_test extends advanced_testcase {
                 'name', question_attempt::PARAM_MARK, array('name' => 'frog')));
     }
 }
-
-
-/**
- * These tests use a standard fixture of a {@link question_attempt} with three steps.
- *
- * @copyright  2009 The Open University
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class question_attempt_with_steps_test extends advanced_testcase {
-    private $question;
-    private $qa;
-
-    protected function setUp() {
-        $this->question = test_question_maker::make_question('description');
-        $this->qa = new testable_question_attempt($this->question, 0, null, 2);
-        for ($i = 0; $i < 3; $i++) {
-            $step = new question_attempt_step(array('i' => $i));
-            $this->qa->add_step($step);
-        }
-    }
-
-    protected function tearDown() {
-        $this->qa = null;
-    }
-
-    public function test_get_step_before_start() {
-        $this->setExpectedException('moodle_exception');
-        $step = $this->qa->get_step(-1);
-    }
-
-    public function test_get_step_at_start() {
-        $step = $this->qa->get_step(0);
-        $this->assertEquals(0, $step->get_qt_var('i'));
-    }
-
-    public function test_get_step_at_end() {
-        $step = $this->qa->get_step(2);
-        $this->assertEquals(2, $step->get_qt_var('i'));
-    }
-
-    public function test_get_step_past_end() {
-        $this->setExpectedException('moodle_exception');
-        $step = $this->qa->get_step(3);
-    }
-
-    public function test_get_num_steps() {
-        $this->assertEquals(3, $this->qa->get_num_steps());
-    }
-
-    public function test_get_last_step() {
-        $step = $this->qa->get_last_step();
-        $this->assertEquals(2, $step->get_qt_var('i'));
-    }
-
-    public function test_get_last_qt_var_there1() {
-        $this->assertEquals(2, $this->qa->get_last_qt_var('i'));
-    }
-
-    public function test_get_last_qt_var_there2() {
-        $this->qa->get_step(0)->set_qt_var('_x', 'a value');
-        $this->assertEquals('a value', $this->qa->get_last_qt_var('_x'));
-    }
-
-    public function test_get_last_qt_var_missing() {
-        $this->assertNull($this->qa->get_last_qt_var('notthere'));
-    }
-
-    public function test_get_last_qt_var_missing_default() {
-        $this->assertEquals('default', $this->qa->get_last_qt_var('notthere', 'default'));
-    }
-
-    public function test_get_last_behaviour_var_missing() {
-        $this->assertNull($this->qa->get_last_qt_var('notthere'));
-    }
-
-    public function test_get_last_behaviour_var_there() {
-        $this->qa->get_step(1)->set_behaviour_var('_x', 'a value');
-        $this->assertEquals('a value', '' . $this->qa->get_last_behaviour_var('_x'));
-    }
-
-    public function test_get_state_gets_state_of_last() {
-        $this->qa->get_step(2)->set_state(question_state::$gradedright);
-        $this->qa->get_step(1)->set_state(question_state::$gradedwrong);
-        $this->assertEquals(question_state::$gradedright, $this->qa->get_state());
-    }
-
-    public function test_get_mark_gets_mark_of_last() {
-        $this->assertEquals(2, $this->qa->get_max_mark());
-        $this->qa->get_step(2)->set_fraction(0.5);
-        $this->qa->get_step(1)->set_fraction(0.1);
-        $this->assertEquals(1, $this->qa->get_mark());
-    }
-
-    public function test_get_fraction_gets_fraction_of_last() {
-        $this->qa->get_step(2)->set_fraction(0.5);
-        $this->qa->get_step(1)->set_fraction(0.1);
-        $this->assertEquals(0.5, $this->qa->get_fraction());
-    }
-
-    public function test_get_fraction_returns_null_if_none() {
-        $this->assertNull($this->qa->get_fraction());
-    }
-
-    public function test_format_mark() {
-        $this->qa->get_step(2)->set_fraction(0.5);
-        $this->assertEquals('1.00', $this->qa->format_mark(2));
-    }
-
-    public function test_format_max_mark() {
-        $this->assertEquals('2.0000000', $this->qa->format_max_mark(7));
-    }
-
-    public function test_get_min_fraction() {
-        $this->qa->set_min_fraction(-1);
-        $this->assertEquals(-1, $this->qa->get_min_fraction(0));
-    }
-
-    public function test_cannot_get_min_fraction_before_start() {
-        $qa = new question_attempt($this->question, 0);
-        $this->setExpectedException('moodle_exception');
-        $qa->get_min_fraction();
-    }
-}
-
-
-/**
- * Unit tests for loading data into the {@link question_attempt} class.
- *
- * @copyright  2009 The Open University
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class question_attempt_db_test extends data_loading_method_test_base {
-    public function test_load() {
-        $records = new question_test_recordset(array(
-            array('questionattemptid', 'contextid', 'questionusageid', 'slot',
-                                   'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'flagged',
-                                                                                       'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
-                                                                                                               'attemptstepid', 'sequencenumber', 'state', 'fraction',
-                                                                                                                                                'timecreated', 'userid', 'name', 'value'),
-            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo',              null, 1256233700, 1,       null, null),
-            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete',          null, 1256233705, 1,   'answer',  '1'),
-            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1, '', '', '', 1256233790, 3, 2, 'complete',          null, 1256233710, 1,   'answer',  '0'),
-            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 4, 3, 'complete',          null, 1256233715, 1,   'answer',  '1'),
-            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 5, 4, 'gradedright',  1.0000000, 1256233720, 1,  '-finish',  '1'),
-            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1, '-comment', 'Not good enough!'),
-            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1,    '-mark',  '1'),
-            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1, '-maxmark',  '2'),
-        ));
-
-        $question = test_question_maker::make_question('truefalse', 'true');
-        $question->id = -1;
-
-        question_bank::start_unit_test();
-        question_bank::load_test_question_data($question);
-        $qa = question_attempt::load_from_records($records, 1, new question_usage_null_observer(), 'deferredfeedback');
-        question_bank::end_unit_test();
-
-        $this->assertEquals($question->questiontext, $qa->get_question()->questiontext);
-
-        $this->assertEquals(6, $qa->get_num_steps());
-
-        $step = $qa->get_step(0);
-        $this->assertEquals(question_state::$todo, $step->get_state());
-        $this->assertNull($step->get_fraction());
-        $this->assertEquals(1256233700, $step->get_timecreated());
-        $this->assertEquals(1, $step->get_user_id());
-        $this->assertEquals(array(), $step->get_all_data());
-
-        $step = $qa->get_step(1);
-        $this->assertEquals(question_state::$complete, $step->get_state());
-        $this->assertNull($step->get_fraction());
-        $this->assertEquals(1256233705, $step->get_timecreated());
-        $this->assertEquals(1, $step->get_user_id());
-        $this->assertEquals(array('answer' => '1'), $step->get_all_data());
-
-        $step = $qa->get_step(2);
-        $this->assertEquals(question_state::$complete, $step->get_state());
-        $this->assertNull($step->get_fraction());
-        $this->assertEquals(1256233710, $step->get_timecreated());
-        $this->assertEquals(1, $step->get_user_id());
-        $this->assertEquals(array('answer' => '0'), $step->get_all_data());
-
-        $step = $qa->get_step(3);
-        $this->assertEquals(question_state::$complete, $step->get_state());
-        $this->assertNull($step->get_fraction());
-        $this->assertEquals(1256233715, $step->get_timecreated());
-        $this->assertEquals(1, $step->get_user_id());
-        $this->assertEquals(array('answer' => '1'), $step->get_all_data());
-
-        $step = $qa->get_step(4);
-        $this->assertEquals(question_state::$gradedright, $step->get_state());
-        $this->assertEquals(1, $step->get_fraction());
-        $this->assertEquals(1256233720, $step->get_timecreated());
-        $this->assertEquals(1, $step->get_user_id());
-        $this->assertEquals(array('-finish' => '1'), $step->get_all_data());
-
-        $step = $qa->get_step(5);
-        $this->assertEquals(question_state::$mangrpartial, $step->get_state());
-        $this->assertEquals(0.5, $step->get_fraction());
-        $this->assertEquals(1256233790, $step->get_timecreated());
-        $this->assertEquals(1, $step->get_user_id());
-        $this->assertEquals(array('-comment' => 'Not good enough!', '-mark' => '1', '-maxmark' => '2'),
-                $step->get_all_data());
-    }
-
-    public function test_load_missing_question() {
-        $records = new question_test_recordset(array(
-            array('questionattemptid', 'contextid', 'questionusageid', 'slot',
-                                   'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'flagged',
-                                                                                       'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
-                                                                                                               'attemptstepid', 'sequencenumber', 'state', 'fraction',
-                                                                                                                                                'timecreated', 'userid', 'name', 'value'),
-            array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo',              null, 1256233700, 1,       null, null),
-        ));
-
-        question_bank::start_unit_test();
-        $qa = question_attempt::load_from_records($records, 1, new question_usage_null_observer(), 'deferredfeedback');
-        question_bank::end_unit_test();
-
-        $missingq = question_bank::get_qtype('missingtype')->make_deleted_instance(-1, 2);
-        $this->assertEquals($missingq, $qa->get_question());
-
-        $this->assertEquals(1, $qa->get_num_steps());
-
-        $step = $qa->get_step(0);
-        $this->assertEquals(question_state::$todo, $step->get_state());
-        $this->assertNull($step->get_fraction());
-        $this->assertEquals(1256233700, $step->get_timecreated());
-        $this->assertEquals(1, $step->get_user_id());
-        $this->assertEquals(array(), $step->get_all_data());
-    }
-}
diff --git a/question/engine/tests/questionattempt_with_steps_test.php b/question/engine/tests/questionattempt_with_steps_test.php
new file mode 100644 (file)
index 0000000..72553fa
--- /dev/null
@@ -0,0 +1,157 @@
+<?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/>.
+
+/**
+ * This file contains tests for the question_attempt class.
+ *
+ * Action methods like start, process_action and finish are assumed to be
+ * tested by walkthrough tests in the various behaviours.
+ *
+ * @package    moodlecore
+ * @subpackage questionengine
+ * @copyright  2009 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once(dirname(__FILE__) . '/../lib.php');
+require_once(dirname(__FILE__) . '/helpers.php');
+
+
+/**
+ * These tests use a standard fixture of a {@link question_attempt} with three steps.
+ *
+ * @copyright  2009 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_attempt_with_steps_test extends advanced_testcase {
+    private $question;
+    private $qa;
+
+    protected function setUp() {
+        $this->question = test_question_maker::make_question('description');
+        $this->qa = new testable_question_attempt($this->question, 0, null, 2);
+        for ($i = 0; $i < 3; $i++) {
+            $step = new question_attempt_step(array('i' => $i));
+            $this->qa->add_step($step);
+        }
+    }
+
+    protected function tearDown() {
+        $this->qa = null;
+    }
+
+    public function test_get_step_before_start() {
+        $this->setExpectedException('moodle_exception');
+        $step = $this->qa->get_step(-1);
+    }
+
+    public function test_get_step_at_start() {
+        $step = $this->qa->get_step(0);
+        $this->assertEquals(0, $step->get_qt_var('i'));
+    }
+
+    public function test_get_step_at_end() {
+        $step = $this->qa->get_step(2);
+        $this->assertEquals(2, $step->get_qt_var('i'));
+    }
+
+    public function test_get_step_past_end() {
+        $this->setExpectedException('moodle_exception');
+        $step = $this->qa->get_step(3);
+    }
+
+    public function test_get_num_steps() {
+        $this->assertEquals(3, $this->qa->get_num_steps());
+    }
+
+    public function test_get_last_step() {
+        $step = $this->qa->get_last_step();
+        $this->assertEquals(2, $step->get_qt_var('i'));
+    }
+
+    public function test_get_last_qt_var_there1() {
+        $this->assertEquals(2, $this->qa->get_last_qt_var('i'));
+    }
+
+    public function test_get_last_qt_var_there2() {
+        $this->qa->get_step(0)->set_qt_var('_x', 'a value');
+        $this->assertEquals('a value', $this->qa->get_last_qt_var('_x'));
+    }
+
+    public function test_get_last_qt_var_missing() {
+        $this->assertNull($this->qa->get_last_qt_var('notthere'));
+    }
+
+    public function test_get_last_qt_var_missing_default() {
+        $this->assertEquals('default', $this->qa->get_last_qt_var('notthere', 'default'));
+    }
+
+    public function test_get_last_behaviour_var_missing() {
+        $this->assertNull($this->qa->get_last_qt_var('notthere'));
+    }
+
+    public function test_get_last_behaviour_var_there() {
+        $this->qa->get_step(1)->set_behaviour_var('_x', 'a value');
+        $this->assertEquals('a value', '' . $this->qa->get_last_behaviour_var('_x'));
+    }
+
+    public function test_get_state_gets_state_of_last() {
+        $this->qa->get_step(2)->set_state(question_state::$gradedright);
+        $this->qa->get_step(1)->set_state(question_state::$gradedwrong);
+        $this->assertEquals(question_state::$gradedright, $this->qa->get_state());
+    }
+
+    public function test_get_mark_gets_mark_of_last() {
+        $this->assertEquals(2, $this->qa->get_max_mark());
+        $this->qa->get_step(2)->set_fraction(0.5);
+        $this->qa->get_step(1)->set_fraction(0.1);
+        $this->assertEquals(1, $this->qa->get_mark());
+    }
+
+    public function test_get_fraction_gets_fraction_of_last() {
+        $this->qa->get_step(2)->set_fraction(0.5);
+        $this->qa->get_step(1)->set_fraction(0.1);
+        $this->assertEquals(0.5, $this->qa->get_fraction());
+    }
+
+    public function test_get_fraction_returns_null_if_none() {
+        $this->assertNull($this->qa->get_fraction());
+    }
+
+    public function test_format_mark() {
+        $this->qa->get_step(2)->set_fraction(0.5);
+        $this->assertEquals('1.00', $this->qa->format_mark(2));
+    }
+
+    public function test_format_max_mark() {
+        $this->assertEquals('2.0000000', $this->qa->format_max_mark(7));
+    }
+
+    public function test_get_min_fraction() {
+        $this->qa->set_min_fraction(-1);
+        $this->assertEquals(-1, $this->qa->get_min_fraction(0));
+    }
+
+    public function test_cannot_get_min_fraction_before_start() {
+        $qa = new question_attempt($this->question, 0);
+        $this->setExpectedException('moodle_exception');
+        $qa->get_min_fraction();
+    }
+}
diff --git a/question/engine/tests/questionusage_autosave_test.php b/question/engine/tests/questionusage_autosave_test.php
new file mode 100644 (file)
index 0000000..9090d42
--- /dev/null
@@ -0,0 +1,562 @@
+<?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/>.
+
+/**
+ * This file contains tests for the autosave code in the question_usage class.
+ *
+ * @package    moodlecore
+ * @subpackage questionengine
+ * @copyright  2013 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once(dirname(__FILE__) . '/../lib.php');
+require_once(dirname(__FILE__) . '/helpers.php');
+
+
+/**
+ * Unit tests for the autosave parts of the {@link question_usage} class.
+ *
+ * @copyright 2013 The Open University
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_usage_autosave_test extends qbehaviour_walkthrough_test_base {
+
+    public function test_autosave_then_display() {
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $generator->create_question_category();
+        $question = $generator->create_question('shortanswer', null,
+                array('category' => $cat->id));
+
+        // Start attempt at a shortanswer question.
+        $q = question_bank::load_question($question->id);
+        $this->start_attempt_at_question($q, 'deferredfeedback', 1);
+
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_step_count(1);
+
+        // Process a response and check the expected result.
+        $this->process_submission(array('answer' => 'first response'));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'first response');
+
+        // Process an autosave.
+        $this->load_quba();
+        $this->process_autosave(array('answer' => 'second response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(3);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->load_quba();
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'second response');
+
+        $this->delete_quba();
+    }
+
+    public function test_autosave_then_autosave_different_data() {
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $generator->create_question_category();
+        $question = $generator->create_question('shortanswer', null,
+                array('category' => $cat->id));
+
+        // Start attempt at a shortanswer question.
+        $q = question_bank::load_question($question->id);
+        $this->start_attempt_at_question($q, 'deferredfeedback', 1);
+
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_step_count(1);
+
+        // Process a response and check the expected result.
+        $this->process_submission(array('answer' => 'first response'));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'first response');
+
+        // Process an autosave.
+        $this->load_quba();
+        $this->process_autosave(array('answer' => 'second response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(3);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->load_quba();
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'second response');
+
+        // Process a second autosave.
+        $this->load_quba();
+        $this->process_autosave(array('answer' => 'third response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(3);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->load_quba();
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'third response');
+
+        $this->delete_quba();
+    }
+
+    public function test_autosave_then_autosave_same_data() {
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $generator->create_question_category();
+        $question = $generator->create_question('shortanswer', null,
+                array('category' => $cat->id));
+
+        // Start attempt at a shortanswer question.
+        $q = question_bank::load_question($question->id);
+        $this->start_attempt_at_question($q, 'deferredfeedback', 1);
+
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_step_count(1);
+
+        // Process a response and check the expected result.
+        $this->process_submission(array('answer' => 'first response'));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'first response');
+
+        // Process an autosave.
+        $this->load_quba();
+        $this->process_autosave(array('answer' => 'second response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(3);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->load_quba();
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'second response');
+
+        $stepid = $this->quba->get_question_attempt($this->slot)->get_last_step()->get_id();
+
+        // Process a second autosave.
+        $this->load_quba();
+        $this->process_autosave(array('answer' => 'second response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(3);
+        $this->save_quba();
+
+        // Try to check it is really the same step
+        $newstepid = $this->quba->get_question_attempt($this->slot)->get_last_step()->get_id();
+        $this->assertEquals($stepid, $newstepid);
+
+        // Now check how that is re-displayed.
+        $this->load_quba();
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'second response');
+
+        $this->delete_quba();
+    }
+
+    public function test_autosave_then_autosave_original_data() {
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $generator->create_question_category();
+        $question = $generator->create_question('shortanswer', null,
+                array('category' => $cat->id));
+
+        // Start attempt at a shortanswer question.
+        $q = question_bank::load_question($question->id);
+        $this->start_attempt_at_question($q, 'deferredfeedback', 1);
+
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_step_count(1);
+
+        // Process a response and check the expected result.
+        $this->process_submission(array('answer' => 'first response'));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'first response');
+
+        // Process an autosave.
+        $this->load_quba();
+        $this->process_autosave(array('answer' => 'second response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(3);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->load_quba();
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'second response');
+
+        // Process a second autosave saving the original response.
+        // This should remove the autosave step.
+        $this->load_quba();
+        $this->process_autosave(array('answer' => 'first response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->load_quba();
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'first response');
+
+        $this->delete_quba();
+    }
+
+    public function test_autosave_then_real_save() {
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $generator->create_question_category();
+        $question = $generator->create_question('shortanswer', null,
+                array('category' => $cat->id));
+
+        // Start attempt at a shortanswer question.
+        $q = question_bank::load_question($question->id);
+        $this->start_attempt_at_question($q, 'deferredfeedback', 1);
+
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_step_count(1);
+
+        // Process a response and check the expected result.
+        $this->process_submission(array('answer' => 'first response'));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'first response');
+
+        // Process an autosave.
+        $this->load_quba();
+        $this->process_autosave(array('answer' => 'second response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(3);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->load_quba();
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'second response');
+
+        // Now save for real a third response.
+        $this->process_submission(array('answer' => 'third response'));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(3);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'third response');
+    }
+
+    public function test_autosave_then_real_save_same() {
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $generator->create_question_category();
+        $question = $generator->create_question('shortanswer', null,
+                array('category' => $cat->id));
+
+        // Start attempt at a shortanswer question.
+        $q = question_bank::load_question($question->id);
+        $this->start_attempt_at_question($q, 'deferredfeedback', 1);
+
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_step_count(1);
+
+        // Process a response and check the expected result.
+        $this->process_submission(array('answer' => 'first response'));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'first response');
+
+        // Process an autosave.
+        $this->load_quba();
+        $this->process_autosave(array('answer' => 'second response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(3);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->load_quba();
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'second response');
+
+        // Now save for real of the same response.
+        $this->process_submission(array('answer' => 'second response'));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(3);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'second response');
+    }
+
+    public function test_autosave_then_submit() {
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $generator->create_question_category();
+        $question = $generator->create_question('shortanswer', null,
+                array('category' => $cat->id));
+
+        // Start attempt at a shortanswer question.
+        $q = question_bank::load_question($question->id);
+        $this->start_attempt_at_question($q, 'deferredfeedback', 1);
+
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_step_count(1);
+
+        // Process a response and check the expected result.
+        $this->process_submission(array('answer' => 'first response'));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'first response');
+
+        // Process an autosave.
+        $this->load_quba();
+        $this->process_autosave(array('answer' => 'second response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(3);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->load_quba();
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'second response');
+
+        // Now submit a third response.
+        $this->process_submission(array('answer' => 'third response'));
+        $this->quba->finish_all_questions();
+
+        $this->check_current_state(question_state::$gradedwrong);
+        $this->check_current_mark(0);
+        $this->check_step_count(4);
+        $this->save_quba();
+
+        // Now check how that is re-displayed.
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'third response', false);
+    }
+
+    public function test_autosave_and_save_concurrently() {
+        // This test simulates the following scenario:
+        // 1. Student looking at a page of the quiz, and edits a field then waits.
+        // 2. Autosave starts.
+        // 3. Student immediately clicks Next, which submits the current page.
+        // In this situation, the real submit should beat the autosave, even
+        // thought they happen concurrently. We simulate this by opening a
+        // second db connections.
+        global $DB;
+
+        // Open second connection
+        $cfg = $DB->export_dbconfig();
+        if (!isset($cfg->dboptions)) {
+            $cfg->dboptions = array();
+        }
+        $DB2 = moodle_database::get_driver_instance($cfg->dbtype, $cfg->dblibrary);
+        $DB2->connect($cfg->dbhost, $cfg->dbuser, $cfg->dbpass, $cfg->dbname, $cfg->prefix, $cfg->dboptions);
+
+        // Since we need to commit our transactions in a given order, close the
+        // standard unit test transaction.
+        $this->preventResetByRollback();
+
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $generator->create_question_category();
+        $question = $generator->create_question('shortanswer', null,
+                array('category' => $cat->id));
+
+        // Start attempt at a shortanswer question.
+        $q = question_bank::load_question($question->id);
+        $this->start_attempt_at_question($q, 'deferredfeedback', 1);
+        $this->save_quba();
+
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_step_count(1);
+
+        // Start to process an autosave on $DB.
+        $transaction = $DB->start_delegated_transaction();
+        $this->load_quba($DB);
+        $this->process_autosave(array('answer' => 'autosaved response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->save_quba($DB); // Don't commit the transaction yet.
+
+        // Now process a real submit on $DB2 (using a different response).
+        $transaction2 = $DB2->start_delegated_transaction();
+        $this->load_quba($DB2);
+        $this->process_submission(array('answer' => 'real response'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+
+        // Now commit the first transaction.
+        $transaction->allow_commit();
+
+        // Now commit the other transaction.
+        $this->save_quba($DB2);
+        $transaction2->allow_commit();
+
+        // Now re-load and check how that is re-displayed.
+        $this->load_quba();
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'real response');
+
+        $DB2->dispose();
+    }
+
+    public function test_concurrent_autosaves() {
+        // This test simulates the following scenario:
+        // 1. Student opens  a page of the quiz in two separate browser.
+        // 2. Autosave starts in both at the same time.
+        // In this situation, one autosave will work, and the other one will
+        // get a unique key violation error. This is OK.
+        global $DB;
+
+        // Open second connection
+        $cfg = $DB->export_dbconfig();
+        if (!isset($cfg->dboptions)) {
+            $cfg->dboptions = array();
+        }
+        $DB2 = moodle_database::get_driver_instance($cfg->dbtype, $cfg->dblibrary);
+        $DB2->connect($cfg->dbhost, $cfg->dbuser, $cfg->dbpass, $cfg->dbname, $cfg->prefix, $cfg->dboptions);
+
+        // Since we need to commit our transactions in a given order, close the
+        // standard unit test transaction.
+        $this->preventResetByRollback();
+
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $generator->create_question_category();
+        $question = $generator->create_question('shortanswer', null,
+                array('category' => $cat->id));
+
+        // Start attempt at a shortanswer question.
+        $q = question_bank::load_question($question->id);
+        $this->start_attempt_at_question($q, 'deferredfeedback', 1);
+        $this->save_quba();
+
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_step_count(1);
+
+        // Start to process an autosave on $DB.
+        $transaction = $DB->start_delegated_transaction();
+        $this->load_quba($DB);
+        $this->process_autosave(array('answer' => 'autosaved response 1'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->save_quba($DB); // Don't commit the transaction yet.
+
+        // Now process a real submit on $DB2 (using a different response).
+        $transaction2 = $DB2->start_delegated_transaction();
+        $this->load_quba($DB2);
+        $this->process_autosave(array('answer' => 'autosaved response 2'));
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+
+        // Now commit the first transaction.
+        $transaction->allow_commit();
+
+        // Now commit the other transaction.
+        $this->setExpectedException('dml_write_exception');
+        $this->save_quba($DB2);
+        $transaction2->allow_commit();
+
+        // Now re-load and check how that is re-displayed.
+        $this->load_quba();
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->render();
+        $this->check_output_contains_text_input('answer', 'autosaved response 1');
+
+        $DB2->dispose();
+    }
+}
index 027bf3f..0b0b8b5 100644 (file)
@@ -31,40 +31,6 @@ require_once(dirname(__FILE__) . '/../lib.php');
 require_once(dirname(__FILE__) . '/helpers.php');
 
 
-/**
- * Test subclass to allow access to some protected data so that the correct
- * behaviour can be verified.
- *
- * @copyright  2012 The Open University
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class testable_question_engine_unit_of_work extends question_engine_unit_of_work {
-    public function get_modified() {
-        return $this->modified;
-    }
-
-    public function get_attempts_added() {
-        return $this->attemptsadded;
-    }
-
-    public function get_attempts_modified() {
-        return $this->attemptsmodified;
-    }
-
-    public function get_steps_added() {
-        return $this->stepsadded;
-    }
-
-    public function get_steps_modified() {
-        return $this->stepsmodified;
-    }
-
-    public function get_steps_deleted() {
-        return $this->stepsdeleted;
-    }
-}
-
-
 /**
  * Unit tests for the {@link question_engine_unit_of_work} class.
  *
@@ -98,7 +64,7 @@ class question_engine_unit_of_work_test extends data_loading_method_test_base {
         $this->setup_initial_test_state($this->get_test_data());
      }
 
-    public function testDown() {
+    public function tearDown() {
         question_bank::end_unit_test();
     }
 
index 2fd9f28..1fc91b2 100644 (file)
@@ -59,4 +59,29 @@ class core_question_generator extends component_generator_base {
         $record['id'] = $DB->insert_record('question_categories', $record);
         return (object) $record;
     }
+
+    /**
+     * Create a new question. The question is initialised using one of the
+     * examples from the appropriate {@link question_test_helper} subclass.
+     * Then, any files you want to change from the value in the base example you
+     * can override using $overrides.
+     * @param string $qtype the question type to create an example of.
+     * @param string $which as for the corresponding argument of
+     *      {@link question_test_helper::get_question_form_data}. null for the default one.
+     * @param array|stdClass $overrides any fields that should be different from the base example.
+     */
+    public function create_question($qtype, $which = null, $overrides = null) {
+        global $CFG;
+        require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
+
+        $fromform = test_question_maker::get_question_form_data($qtype, $which);
+        $fromform = (object) $this->datagenerator->combine_defaults_and_record(
+                (array) $fromform, $overrides);
+
+        $question = new stdClass();
+        $question->category  = $fromform->category;
+        $question->qtype     = $qtype;
+        $question->createdby = 0;
+        return question_bank::get_qtype($qtype)->save_question($question, $fromform);
+    }
 }
index 400026f..a67a942 100644 (file)
@@ -360,7 +360,7 @@ class qtype_match_walkthrough_test extends qbehaviour_walkthrough_test_base {
                 '(1, 2] -> 1 < x ≤ 2; [1, 2] -> 1 ≤ x ≤ 2; [1, 2) -> 1 ≤ x < 2';
 
         $this->process_submission($rightresponse);
-        $this->process_submission(array('-finish' => 1));
+        $this->finish();
 
         $this->assertEquals($rightresponsesummary, $m->summarise_response($rightresponse));
 
index ce7400f..732631f 100644 (file)
@@ -82,7 +82,7 @@ class qtype_multianswer_walkthrough_test extends qbehaviour_walkthrough_test_bas
                 $this->get_does_not_contain_validation_error_expectation());
 
         // Now submit all and finish.
-        $this->process_submission(array('-finish' => 1));
+        $this->finish();
 
         // Verify.
         $this->check_current_state(question_state::$gradedpartial);
@@ -109,7 +109,7 @@ class qtype_multianswer_walkthrough_test extends qbehaviour_walkthrough_test_bas
                 $this->get_does_not_contain_validation_error_expectation());
 
         // Now submit all and finish.
-        $this->process_submission(array('-finish' => 1));
+        $this->finish();
 
         // Verify.
         $this->check_current_state(question_state::$gaveup);
@@ -148,7 +148,7 @@ class qtype_multianswer_walkthrough_test extends qbehaviour_walkthrough_test_bas
                 $this->get_does_not_contain_validation_error_expectation());
 
         // Now submit all and finish.
-        $this->process_submission(array('-finish' => 1));
+        $this->finish();
 
         // Verify.
         $this->check_current_state(question_state::$gradedright);
@@ -187,7 +187,7 @@ class qtype_multianswer_walkthrough_test extends qbehaviour_walkthrough_test_bas
                 $this->get_does_not_contain_validation_error_expectation());
 
         // Now submit all and finish.
-        $this->process_submission(array('-finish' => 1));
+        $this->finish();
 
         // Verify.
         $this->check_current_state(question_state::$gradedwrong);
index bac4a2e..db73550 100644 (file)
@@ -162,7 +162,7 @@ class qtype_numerical_walkthrough_test extends qbehaviour_walkthrough_test_base
                 $this->get_no_hint_visible_expectation());
 
         // Submit all and finish.
-        $this->process_submission(array('-finish' => '1'));
+        $this->finish();
 
         // Verify.
         $this->check_current_state(question_state::$gradedpartial);
@@ -261,7 +261,7 @@ class qtype_numerical_walkthrough_test extends qbehaviour_walkthrough_test_base
                 $this->get_no_hint_visible_expectation());
 
         // Submit all and finish.
-        $this->process_submission(array('-finish' => '1'));
+        $this->finish();
 
         // Verify.
         $this->check_current_state(question_state::$gradedright);
index 533b580..40180a7 100644 (file)
@@ -87,6 +87,32 @@ class qtype_shortanswer_test_helper extends question_test_helper {
         return $qdata;
     }
 
+    /**
+     * Gets the question data for a shortanswer question with with correct
+     * ansewer 'frog', partially correct answer 'toad' and defaultmark 1.
+     * This question also has a '*' match anything answer.
+     * @return stdClass
+     */
+    public function get_shortanswer_question_form_data_frogtoad() {
+        $fromform = new stdClass();
+        test_question_maker::initialise_question_form_data($fromform);
+
+        $fromform->qtype = 'shortanswer';
+        $fromform->name = 'Short answer question';
+        $fromform->questiontext = array('text' => 'Name an amphibian: __________', 'format' => FORMAT_HTML);
+        $fromform->generalfeedback = array('text' => 'Generalfeedback: frog or toad would have been OK.', 'format' => FORMAT_HTML);
+        $fromform->usecase = false;
+        $fromform->answer = array('frog', 'toad', '*');
+        $fromform->fraction = array(1.0, 0.8, 0.0);
+        $fromform->feedback = array(
+            array('text' => 'Frog is a very good answer.', 'format' => FORMAT_HTML),
+            array('text' => 'Toad is an OK good answer.', 'format' => FORMAT_HTML),
+            array('text' => 'That is a bad answer.', 'format' => FORMAT_HTML),
+        );
+
+        return $fromform;
+    }
+
     /**
      * Makes a shortanswer question with just the correct ansewer 'frog', and
      * no other answer matching.
index 809530c..e619654 100644 (file)
@@ -1,5 +1,16 @@
 This files describes API changes for question type plugins.
 
+=== 2.5 ===
+
+* There have been some tweaks to the helper class that is used to right
+  walkthrough tests. You should not have to change your code, but you might
+  like to take a look at some of the new helper methods available. In particular,
+  if you had any code that did
+  $this->process_submission(array('-finish' => 1));
+  you should change that to
+  $this->finish();
+
+
 === 2.3.5 / 2.4.2 / 2.5 ===
 
 * The special value question_attempt::PARAM_CLEANHTML_FILES that could be used
similarity index 99%
rename from lib/tests/behat/behat_filepicker.php
rename to repository/tests/behat/behat_filepicker.php
index 68a79b1..10c9b3f 100644 (file)
@@ -25,7 +25,7 @@
 
 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 
-require_once(__DIR__ . '/../../behat/behat_files.php');
+require_once(__DIR__ . '/../../../lib/behat/behat_files.php');
 
 use Behat\Mink\Exception\ExpectationException as ExpectationException;
 
diff --git a/repository/tests/behat/create_folders.feature b/repository/tests/behat/create_folders.feature
new file mode 100644 (file)
index 0000000..d34712a
--- /dev/null
@@ -0,0 +1,26 @@
+@core_form @filepicker
+Feature: Create folders in the file manager
+  In order to create a directory structure in a file area
+  As a moodle user
+  I need to create folders and subfolders in a file area
+
+  @javascript
+  Scenario: Create folders and subfolders
+    Given the following "courses" exists:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And I log in as "admin"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "Folder" to section "0"
+    And I fill the moodle form with:
+      | Name | Folder resource |
+      | Description | The description |
+    And I create "Folder 1" folder in "Files" filepicker
+    And I open "Folder 1" folder from "Files" filepicker
+    And I create "SubFolder 1" folder in "Files" filepicker
+    When I open "Files" folder from "Files" filepicker
+    Then I should see "Folder 1"
+    And I open "Folder 1" folder from "Files" filepicker
+    And I should see "SubFolder 1"
+    And I press "Save and return to course"
@@ -1,10 +1,11 @@
-@tool_behat @core_form @filepicker
-Feature: Manipulate filepicker
-  In order to provide external resources
+@core_form @filepicker
+Feature: Zip folders and unzip compressed files
+  In order to download or add contents to file areas easily
   As a moodle user
-  I need to upload files to moodle
+  I need to zip and unzip folders and files
 
-  Background:
+  @javascript
+  Scenario: Zip and unzip folders and files
     Given the following "courses" exists:
       | fullname | shortname | category |
       | Course 1 | C1 | 0 |
@@ -18,18 +19,7 @@ Feature: Manipulate filepicker
     And I create "Folder 1" folder in "Files" filepicker
     And I open "Folder 1" folder from "Files" filepicker
     And I create "SubFolder 1" folder in "Files" filepicker
-
-  @javascript
-  Scenario: Create folders and subfolders
-    When I open "Files" folder from "Files" filepicker
-    Then I should see "Folder 1"
-    And I open "Folder 1" folder from "Files" filepicker
-    And I should see "SubFolder 1"
-    And I press "Save and return to course"
-
-  @javascript
-  Scenario: Zip and unzip folders and files
-    Given I open "Files" folder from "Files" filepicker
+    And I open "Files" folder from "Files" filepicker
     And I zip "Folder 1" folder from "Files" filepicker
     And I delete "Folder 1" from "Files" filepicker
     When I unzip "Folder 1.zip" file from "Files" filepicker
index 63baab8..2f04575 100644 (file)
@@ -4,21 +4,6 @@ Feature: Upload files
   As a moodle user
   I need to upload files
 
-  @javascript
-  Scenario: Upload a file in a single file filepicker
-    Given I log in as "admin"
-    And I expand "Front page settings" node
-    And I expand "Site administration" node
-    And I expand "Users" node
-    And I expand "Accounts" node
-    And I follow "Upload users"
-    When I upload "lib/tests/fixtures/upload_users.csv" file to "File" filepicker
-    And I press "Upload users"
-    Then I should see "Upload users preview"
-    And I should see "Teacher"
-    And I should see "teacher1@teacher1.com"
-    And I press "Cancel"
-
   @javascript
   Scenario: Upload a file in a multiple file filepicker
     Given the following "courses" exists:
index 84bfefb..5a2fdd8 100644 (file)
@@ -156,4 +156,7 @@ if ($thisdevice == "default" || $thisdevice == "tablet" || optional_param('mymob
 
 // Sets a custom render factory to use with the theme, used when working with custom renderers.
 $THEME->rendererfactory = 'theme_overridden_renderer_factory';
-$THEME->csspostprocess = 'mymobile_user_settings';
\ No newline at end of file
+$THEME->csspostprocess = 'mymobile_user_settings';
+
+// Disables CSS Optimiser for MyMobile theme.
+$THEME->supportscssoptimisation = false;