Merge branch 'MDL-62610-master' of git://github.com/lameze/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 3 Oct 2018 16:00:08 +0000 (18:00 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 3 Oct 2018 16:00:08 +0000 (18:00 +0200)
33 files changed:
lang/en/moodle.php
lib/behat/behat_base.php
mod/quiz/attemptlib.php
mod/quiz/locallib.php
mod/quiz/report/grading/tests/behat/grading.feature
mod/quiz/report/overview/tests/behat/basic.feature
mod/quiz/report/reportlib.php
mod/quiz/report/responses/tests/behat/basic.feature
mod/quiz/report/statistics/lang/en/quiz_statistics.php
mod/quiz/report/statistics/statistics_table.php
mod/quiz/report/statistics/tests/behat/basic.feature [new file with mode: 0644]
mod/quiz/report/statistics/tests/statistics_table_test.php [new file with mode: 0644]
mod/quiz/tests/behat/attempt_basic.feature
mod/quiz/tests/behat/behat_mod_quiz.php
mod/quiz/tests/behat/completion_condition_attempts_used.feature
mod/quiz/tests/behat/completion_condition_passing_grade.feature
mod/quiz/tests/behat/preview.feature
mod/quiz/tests/behat/quiz_reset.feature
mod/quiz/tests/generator/lib.php
mod/quiz/tests/reportlib_test.php
mod/workshop/form/numerrors/edit_form.php
mod/workshop/form/numerrors/lang/en/workshopform_numerrors.php
question/behaviour/interactive/behaviour.php
question/classes/statistics/questions/all_calculated_for_qubaid_condition.php
question/classes/statistics/questions/calculated_question_summary.php [new file with mode: 0644]
question/tests/calculated_question_summary_test.php [new file with mode: 0644]
question/tests/generator/lib.php
question/type/essay/question.php
question/type/missingtype/question.php
question/type/numerical/question.php
question/type/questionbase.php
question/type/shortanswer/question.php
question/type/truefalse/question.php

index 1e5b746..a3b7bb1 100644 (file)
@@ -1513,6 +1513,7 @@ $string['payments'] = 'Payments';
 $string['paymentsorry'] = 'Thank you for your payment!  Unfortunately your payment has not yet been fully processed, and you are not yet registered to enter the course "{$a->fullname}".  Please try continuing to the course in a few seconds, but if you continue to have trouble then please alert the {$a->teacher} or the site administrator';
 $string['paymentthanks'] = 'Thank you for your payment!  You are now enrolled in your course:<br />"{$a}"';
 $string['pendingrequests'] = 'Pending requests';
+$string['percents'] = '{$a} %';
 $string['periodending'] = 'Period ending ({$a})';
 $string['perpage'] = 'Per page';
 $string['perpagea'] = 'Per page: {$a}';
index e7c631f..f1daac0 100644 (file)
@@ -946,6 +946,34 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
         return $DB->get_record('user', ['id' => $userid]);
     }
 
+    /**
+     * Set current $USER, reset access cache.
+     *
+     * In some cases, behat will execute the code as admin but in many cases we need to set an specific user as some
+     * API's might rely on the logged user to take some action.
+     *
+     * @param null|int|stdClass $user user record, null or 0 means non-logged-in, positive integer means userid
+     */
+    public static function set_user($user = null) {
+        global $DB;
+
+        if (is_object($user)) {
+            $user = clone($user);
+        } else if (!$user) {
+            // Assign valid data to admin user (some generator-related code needs a valid user).
+            $user = $DB->get_record('user', array('username' => 'admin'));
+        } else {
+            $user = $DB->get_record('user', array('id' => $user));
+        }
+        unset($user->description);
+        unset($user->access);
+        unset($user->preference);
+
+        // Ensure session is empty, as it may contain caches and user specific info.
+        \core\session\manager::init_empty_session();
+
+        \core\session\manager::set_user($user);
+    }
     /**
      * Trigger click on node via javascript instead of actually clicking on it via pointer.
      *
index c65a659..00e1eb1 100644 (file)
@@ -1101,7 +1101,7 @@ class quiz_attempt {
      * @return question_usage_by_activity the usage.
      */
     public function get_question_usage() {
-        if (!PHPUNIT_TEST) {
+        if (!(PHPUNIT_TEST || defined('BEHAT_TEST'))) {
             throw new coding_exception('get_question_usage is only for use in unit tests. ' .
                     'For other operations, use the quiz_attempt api, or extend it properly.');
         }
@@ -1814,10 +1814,12 @@ class quiz_attempt {
      * @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.
      * @param bool $becomingoverdue
-     * @param array|null $simulatedresponses If not null, then we are testing, and this is an array of simulated data, keys are slot
-     *                                          nos and values are arrays representing student responses which will be passed to
-     *                                          question_definition::prepare_simulated_post_data method and then have the
-     *                                          appropriate prefix added.
+     * @param array|null $simulatedresponses If not null, then we are testing, and this is an array of simulated data.
+     *      There are two formats supported here, for historical reasons. The newer approach is to pass an array created by
+     *      {@link core_question_generator::get_simulated_post_data_for_questions_in_usage()}.
+     *      the second is to pass an array slot no => contains arrays representing student
+     *      responses which will be passed to {@link question_definition::prepare_simulated_post_data()}.
+     *      This second method will probably get deprecated one day.
      */
     public function process_submitted_actions($timestamp, $becomingoverdue = false, $simulatedresponses = null) {
         global $DB;
@@ -1825,7 +1827,12 @@ class quiz_attempt {
         $transaction = $DB->start_delegated_transaction();
 
         if ($simulatedresponses !== null) {
-            $simulatedpostdata = $this->quba->prepare_simulated_post_data($simulatedresponses);
+            if (is_int(key($simulatedresponses))) {
+                // Legacy approach. Should be removed one day.
+                $simulatedpostdata = $this->quba->prepare_simulated_post_data($simulatedresponses);
+            } else {
+                $simulatedpostdata = $simulatedresponses;
+            }
         } else {
             $simulatedpostdata = null;
         }
index e27fbc8..0c46d27 100644 (file)
@@ -2373,14 +2373,19 @@ function quiz_validate_new_attempt(quiz $quizobj, quiz_access_manager $accessman
 /**
  * Prepare and start a new attempt deleting the previous preview attempts.
  *
- * @param  quiz $quizobj quiz object
- * @param  int $attemptnumber the attempt number
- * @param  object $lastattempt last attempt object
+ * @param quiz $quizobj quiz object
+ * @param int $attemptnumber the attempt number
+ * @param object $lastattempt last attempt object
  * @param bool $offlineattempt whether is an offline attempt or not
+ * @param array $forcedrandomquestions slot number => question id. Used for random questions,
+ *      to force the choice of a particular actual question. Intended for testing purposes only.
+ * @param array $forcedvariants slot number => variant. Used for questions with variants,
+ *      to force the choice of a particular variant. Intended for testing purposes only.
  * @return object the new attempt
  * @since  Moodle 3.1
  */
-function quiz_prepare_and_start_new_attempt(quiz $quizobj, $attemptnumber, $lastattempt, $offlineattempt = false) {
+function quiz_prepare_and_start_new_attempt(quiz $quizobj, $attemptnumber, $lastattempt,
+        $offlineattempt = false, $forcedrandomquestions = [], $forcedvariants = []) {
     global $DB, $USER;
 
     // Delete any previous preview attempts belonging to this user.
@@ -2394,7 +2399,8 @@ function quiz_prepare_and_start_new_attempt(quiz $quizobj, $attemptnumber, $last
     $attempt = quiz_create_attempt($quizobj, $attemptnumber, $lastattempt, $timenow, $quizobj->is_preview_user());
 
     if (!($quizobj->get_quiz()->attemptonlast && $lastattempt)) {
-        $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow);
+        $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow,
+                $forcedrandomquestions, $forcedvariants);
     } else {
         $attempt = quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt);
     }
index 09e648b..840902b 100644 (file)
@@ -17,50 +17,35 @@ Feature: Basic use of the Manual grading report
       | user     | course | role           |
       | teacher1 | C1     | editingteacher |
       | student1 | C1     | student        |
-    And I log in as "teacher1"
-    And I am on "Course 1" course homepage with editing mode on
-    And I add a "Quiz" to section "1" and I fill the form with:
-      | Name        | Quiz 1             |
-      | Description | Quiz 1 description |
-    And I add a "Short answer" question to the "Quiz 1" quiz with:
-      | Question name    | Short answer 001                     |
-      | Question text    | Where is the capital city of France? |
-      | Answer 1         | Paris                                |
-      | Grade            | 100%                                 |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name             | questiontext                         | answer 1 | grade |
+      | Test questions   | shortanswer | Short answer 001 | Where is the capital city of France? | Paris    | 100%  |
+    And the following "activities" exist:
+      | activity   | name   | course | idnumber |
+      | quiz       | Quiz 1 | C1     | quiz1    |
+    And quiz "Quiz 1" contains the following questions:
+      | question          | page |
+      | Short answer 001  | 1    |
 
     # Check report shows nothing when there are no attempts.
+    When I log in as "teacher1"
     And I am on "Course 1" course homepage
     And I follow "Quiz 1"
-    When I navigate to "Results > Manual grading" in current page administration
+    And I navigate to "Results > Manual grading" in current page administration
     Then I should see "Manual grading"
     And I should see "Quiz 1"
     And I should see "Nothing to display"
     And I follow "Also show questions that have been graded automatically"
     And I should see "Nothing to display"
-    And I log out
-
-    # Create an attempt.
-    And I log in as "student1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I press "Attempt quiz now"
-    And I should see "Question 1"
-    And I should see "Not yet answered"
-    And I should see "Where is the capital city of France?"
-    And I set the field "Answer:" to "Paris"
-    And I press "Finish attempt ..."
-    And I should see "Answer saved"
-    And I press "Submit all and finish"
-    And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue"
-    And I log out
 
     # Use the manual grading report.
-    And I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Results > Manual grading" in current page administration
-    And I should see "Manual grading"
-    And I follow "Also show questions that have been graded automatically"
+    And user "student1" has attempted "Quiz 1" with responses:
+      | slot | response |
+      |   1  | Paris    |
+    And I reload the page
     And I should see "Short answer 001"
     And "Short answer 001" row "To grade" column of "questionstograde" table should contain "0"
     And "Short answer 001" row "Already graded" column of "questionstograde" table should contain "0"
index 8799c79..766f37a 100644 (file)
@@ -35,29 +35,14 @@ Feature: Basic use of the Grades report
       | question | page | maxmark |
       | TF1      | 1    |         |
       | TF2      | 1    | 3.0     |
-
-    # Add some attempts
-    And I log in as "student1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I press "Attempt quiz now"
-    And I click on "True" "radio" in the "First question" "question"
-    And I click on "False" "radio" in the "Second question" "question"
-    And I press "Finish attempt ..."
-    And I press "Submit all and finish"
-    And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue"
-    And I log out
-
-    And I log in as "student2"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I press "Attempt quiz now"
-    And I click on "True" "radio" in the "First question" "question"
-    And I click on "True" "radio" in the "Second question" "question"
-    And I press "Finish attempt ..."
-    And I press "Submit all and finish"
-    And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue"
-    And I log out
+    And user "student1" has attempted "Quiz 1" with responses:
+      | slot | response |
+      |   1  | True     |
+      |   2  | False    |
+    And user "student2" has attempted "Quiz 1" with responses:
+      | slot | response |
+      |   1  | True     |
+      |   2  | True     |
 
     # Basic check of the Grades report
     When I log in as "teacher1"
index 7d585b8..4696715 100644 (file)
@@ -327,7 +327,8 @@ function quiz_report_scale_summarks_as_percentage($rawmark, $quiz, $round = true
     if ($round) {
         $mark = quiz_format_grade($quiz, $mark);
     }
-    return $mark . '%';
+
+    return get_string('percents', 'moodle', $mark);
 }
 
 /**
index b8344ea..dc1570d 100644 (file)
@@ -40,29 +40,21 @@ Feature: Basic use of the Responses report
     Then I should see "Attempts: 0"
     And I should see "Nothing to display"
     And I set the field "Attempts from" to "enrolled users who have not attempted the quiz"
-    And I press "Show report"
-    And "Student One" row "State" column of "responses" table should contain "-"
+    And I log out
 
   @javascript
   Scenario: Report works when there are attempts
-    # Add an attempt
-    Given I log in as "student1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I press "Attempt quiz now"
-    And I set the field "Answer" to "1.0"
-    And I press "Check"
-    And I press "Try again"
-    And I set the field "Answer" to "3.0"
-    And I press "Check"
-    And I press "Try again"
-    And I set the field "Answer" to "3.14"
-    And I press "Check"
-    And I press "Finish attempt ..."
-    And I press "Submit all and finish"
-    And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue"
-    And I log out
-
+    Given user "student1" has started an attempt at quiz "Quiz 1"
+    And user "student1" has checked answers in their attempt at quiz "Quiz 1":
+      | slot | response |
+      |   1  | 1.0      |
+    And user "student1" has checked answers in their attempt at quiz "Quiz 1":
+      | slot | response |
+      |   1  | 3.0      |
+    And user "student1" has checked answers in their attempt at quiz "Quiz 1":
+      | slot | response |
+      |   1  | 3.14     |
+    And user "student1" has finished an attempt at quiz "Quiz 1"
     When I log in as "teacher"
     And I am on "Course 1" course homepage
     And I follow "Quiz 1"
index 38895d0..40b5849 100644 (file)
@@ -100,6 +100,8 @@ $string['quizname'] = 'Quiz name';
 $string['quizoverallstatistics'] = 'Quiz overall statistics';
 $string['quizstructureanalysis'] = 'Quiz structure analysis';
 $string['random_guess_score'] = 'Random guess score';
+$string['rangeofvalues'] = 'Range of statistics for these questions';
+$string['rangebetween'] = '{$a->min} − {$a->max}';
 $string['recalculatenow'] = 'Recalculate now';
 $string['reportsettings'] = 'Statistics calculation settings';
 $string['response'] = 'Response';
@@ -115,4 +117,5 @@ $string['statisticsreportgraph'] = 'Statistics for question positions';
 $string['statistics:view'] = 'View statistics report';
 $string['statsfor'] = 'Quiz statistics (for {$a})';
 $string['variant'] = 'Variant';
+$string['viewanalysis'] = 'View details';
 $string['whichtries'] = 'Analyze responses for';
index 348532f..90adc89 100644 (file)
@@ -26,6 +26,8 @@ defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->libdir.'/tablelib.php');
 
+use \core_question\statistics\questions\calculated_question_summary;
+
 /**
  * This table has one row for each question in the quiz, with sub-rows when
  * random questions and variants appear.
@@ -132,12 +134,35 @@ class quiz_statistics_table extends flexible_table {
         parent::setup();
     }
 
+    /**
+     * Open a div tag to wrap statistics table.
+     */
+    public function  wrap_html_start() {
+        // Horrible Moodle 2.0 wide-content work-around.
+        if (!$this->is_downloading()) {
+            echo html_writer::start_tag('div', array('id' => 'tablecontainer',
+                    'class' => 'statistics-tablecontainer'));
+        }
+    }
+
+    /**
+     * Close a statistics table div.
+     */
+    public function wrap_html_finish() {
+        if (!$this->is_downloading()) {
+            echo html_writer::end_tag('div');
+        }
+    }
+
     /**
      * The question number.
      * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
      * @return string contents of this table cell.
      */
     protected function col_number($questionstat) {
+        if ($this->is_calculated_question_summary($questionstat)) {
+            return '';
+        }
         if (!isset($questionstat->question->number)) {
             return '';
         }
@@ -160,7 +185,11 @@ class quiz_statistics_table extends flexible_table {
      * @return string contents of this table cell.
      */
     protected function col_icon($questionstat) {
-        return print_question_icon($questionstat->question, true);
+        if ($this->is_calculated_question_summary($questionstat)) {
+            return '';
+        } else {
+            return print_question_icon($questionstat->question, true);
+        }
     }
 
     /**
@@ -169,8 +198,12 @@ class quiz_statistics_table extends flexible_table {
      * @return string contents of this table cell.
      */
     protected function col_actions($questionstat) {
-        return quiz_question_action_icons($this->quiz, $this->cmid,
-                $questionstat->question, $this->baseurl, $questionstat->variant);
+        if ($this->is_calculated_question_summary($questionstat)) {
+            return '';
+        } else {
+            return quiz_question_action_icons($this->quiz, $this->cmid,
+                    $questionstat->question, $this->baseurl, $questionstat->variant);
+        }
     }
 
     /**
@@ -227,13 +260,16 @@ class quiz_statistics_table extends flexible_table {
                 // Question in a slot, we are not on a page showing structural analysis of one slot,
                 // we don't want linking on those pages.
                 $number = $questionstat->question->number;
+                $israndomquestion = $questionstat->question->qtype == 'random';
                 $url = new moodle_url($baseurl, array('slot' => $questionstat->slot));
-                if ($questionstat->get_variants() || $questionstat->get_sub_question_ids()) {
-                    // Question can be broken down into sub-questions or variants. Link will show structural analysis page.
-                    $name = html_writer::link($url,
-                                              $name,
-                                              array('title' => get_string('slotstructureanalysis', 'quiz_statistics', $number)));
-                } else {
+
+                if ($this->is_calculated_question_summary($questionstat)) {
+                    // Only make the random question summary row name link to the slot structure
+                    // analysis page with specific text to clearly indicate the link to the user.
+                    // Random and variant question rows will render the name without a link to improve clarity
+                    // in the UI.
+                    $name = html_writer::div(get_string('rangeofvalues', 'quiz_statistics'));
+                } else if (!$israndomquestion && !$questionstat->get_variants() && !$questionstat->get_sub_question_ids()) {
                     // Question cannot be broken down into sub-questions or variants. Link will show response analysis page.
                     $name = html_writer::link($url,
                                               $name,
@@ -247,7 +283,9 @@ class quiz_statistics_table extends flexible_table {
             $name = html_writer::tag('div', $name, array('class' => 'dubious'));
         }
 
-        if (!empty($questionstat->minmedianmaxnotice)) {
+        if ($this->is_calculated_question_summary($questionstat)) {
+            $name .= html_writer::link($url, get_string('viewanalysis', 'quiz_statistics'));
+        } else if (!empty($questionstat->minmedianmaxnotice)) {
             $name = get_string($questionstat->minmedianmaxnotice, 'quiz_statistics') . '<br />' . $name;
         }
 
@@ -261,11 +299,16 @@ class quiz_statistics_table extends flexible_table {
      * @return string contents of this table cell.
      */
     protected function col_s($questionstat) {
-        if (!isset($questionstat->s)) {
+        if ($this->is_calculated_question_summary($questionstat)) {
+            list($min, $max) = $questionstat->get_min_max_of('s');
+            $min = $min ?: 0;
+            $max = $max ?: 0;
+            return $this->format_range($min, $max);
+        } else if (!isset($questionstat->s)) {
             return 0;
+        } else {
+            return $questionstat->s;
         }
-
-        return $questionstat->s;
     }
 
     /**
@@ -274,11 +317,14 @@ class quiz_statistics_table extends flexible_table {
      * @return string contents of this table cell.
      */
     protected function col_facility($questionstat) {
-        if (is_null($questionstat->facility)) {
+        if ($this->is_calculated_question_summary($questionstat)) {
+            list($min, $max) = $questionstat->get_min_max_of('facility');
+            return $this->format_percentage_range($min, $max);
+        } else if (is_null($questionstat->facility)) {
             return '';
+        } else {
+            return $this->format_percentage($questionstat->facility);
         }
-
-        return number_format($questionstat->facility*100, 2) . '%';
     }
 
     /**
@@ -287,11 +333,14 @@ class quiz_statistics_table extends flexible_table {
      * @return string contents of this table cell.
      */
     protected function col_sd($questionstat) {
-        if (is_null($questionstat->sd) || $questionstat->maxmark == 0) {
+        if ($this->is_calculated_question_summary($questionstat)) {
+            list($min, $max) = $questionstat->get_min_max_of('sd');
+            return $this->format_percentage_range($min, $max);
+        } else if (is_null($questionstat->sd) || $questionstat->maxmark == 0) {
             return '';
+        } else {
+            return $this->format_percentage($questionstat->sd / $questionstat->maxmark);
         }
-
-        return number_format($questionstat->sd*100 / $questionstat->maxmark, 2) . '%';
     }
 
     /**
@@ -300,11 +349,14 @@ class quiz_statistics_table extends flexible_table {
      * @return string contents of this table cell.
      */
     protected function col_random_guess_score($questionstat) {
-        if (is_null($questionstat->randomguessscore)) {
+        if ($this->is_calculated_question_summary($questionstat)) {
+            list($min, $max) = $questionstat->get_min_max_of('randomguessscore');
+            return $this->format_percentage_range($min, $max);
+        } else if (is_null($questionstat->randomguessscore)) {
             return '';
+        } else {
+            return $this->format_percentage($questionstat->randomguessscore);
         }
-
-        return number_format($questionstat->randomguessscore * 100, 2).'%';
     }
 
     /**
@@ -315,7 +367,19 @@ class quiz_statistics_table extends flexible_table {
      * @return string contents of this table cell.
      */
     protected function col_intended_weight($questionstat) {
-        return quiz_report_scale_summarks_as_percentage($questionstat->maxmark, $this->quiz);
+        if ($this->is_calculated_question_summary($questionstat)) {
+            list($min, $max) = $questionstat->get_min_max_of('maxmark');
+
+            if (is_null($min) && is_null($max)) {
+                return '';
+            } else {
+                $min = quiz_report_scale_summarks_as_percentage($min, $this->quiz);
+                $max = quiz_report_scale_summarks_as_percentage($max, $this->quiz);
+                return $this->format_range($min, $max);
+            }
+        } else {
+            return quiz_report_scale_summarks_as_percentage($questionstat->maxmark, $this->quiz);
+        }
     }
 
     /**
@@ -327,11 +391,22 @@ class quiz_statistics_table extends flexible_table {
     protected function col_effective_weight($questionstat) {
         global $OUTPUT;
 
-        if (is_null($questionstat->effectiveweight)) {
-            return '';
-        }
+        if ($this->is_calculated_question_summary($questionstat)) {
+            list($min, $max) = $questionstat->get_min_max_of('effectiveweight');
+
+            if (is_null($min) && is_null($max)) {
+                return '';
+            } else {
+                list( , $negcovar) = $questionstat->get_min_max_of('negcovar');
+                if ($negcovar) {
+                    $min = get_string('negcovar', 'quiz_statistics');
+                }
 
-        if ($questionstat->negcovar) {
+                return $this->format_range($min, $max);
+            }
+        } else if (is_null($questionstat->effectiveweight)) {
+            return '';
+        } else if ($questionstat->negcovar) {
             $negcovar = get_string('negcovar', 'quiz_statistics');
 
             if (!$this->is_downloading()) {
@@ -341,9 +416,9 @@ class quiz_statistics_table extends flexible_table {
             }
 
             return $negcovar;
+        } else {
+            return $this->format_percentage($questionstat->effectiveweight, false);
         }
-
-        return number_format($questionstat->effectiveweight, 2) . '%';
     }
 
     /**
@@ -354,11 +429,26 @@ class quiz_statistics_table extends flexible_table {
      * @return string contents of this table cell.
      */
     protected function col_discrimination_index($questionstat) {
-        if (!is_numeric($questionstat->discriminationindex)) {
+        if ($this->is_calculated_question_summary($questionstat)) {
+            list($min, $max) = $questionstat->get_min_max_of('discriminationindex');
+
+            if (isset($max)) {
+                $min = $min ?: 0;
+            }
+
+            if (is_numeric($min)) {
+                $min = $this->format_percentage($min, false);
+            }
+            if (is_numeric($max)) {
+                $max = $this->format_percentage($max, false);
+            }
+
+            return $this->format_range($min, $max);
+        } else if (!is_numeric($questionstat->discriminationindex)) {
             return $questionstat->discriminationindex;
+        } else {
+            return $this->format_percentage($questionstat->discriminationindex, false);
         }
-
-        return number_format($questionstat->discriminationindex, 2) . '%';
     }
 
     /**
@@ -368,11 +458,19 @@ class quiz_statistics_table extends flexible_table {
      * @return string contents of this table cell.
      */
     protected function col_discriminative_efficiency($questionstat) {
-        if (!is_numeric($questionstat->discriminativeefficiency)) {
+        if ($this->is_calculated_question_summary($questionstat)) {
+            list($min, $max) = $questionstat->get_min_max_of('discriminativeefficiency');
+
+            if (!is_numeric($min) && !is_numeric($max)) {
+                return '';
+            } else {
+                return $this->format_percentage_range($min, $max, false);
+            }
+        } else if (!is_numeric($questionstat->discriminativeefficiency)) {
             return '';
+        } else {
+            return $this->format_percentage($questionstat->discriminativeefficiency);
         }
-
-        return number_format($questionstat->discriminativeefficiency, 2) . '%';
     }
 
     /**
@@ -381,24 +479,86 @@ class quiz_statistics_table extends flexible_table {
      * @return bool is this question possibly not pulling it's weight?
      */
     protected function is_dubious_question($questionstat) {
-        if (!is_numeric($questionstat->discriminativeefficiency)) {
+        if ($this->is_calculated_question_summary($questionstat)) {
+            // We only care about the minimum value here.
+            // If the minimum value is less than the threshold, then we know that there is at least one value below the threshold.
+            list($discriminativeefficiency) = $questionstat->get_min_max_of('discriminativeefficiency');
+        } else {
+            $discriminativeefficiency = $questionstat->discriminativeefficiency;
+        }
+
+        if (!is_numeric($discriminativeefficiency)) {
             return false;
         }
 
-        return $questionstat->discriminativeefficiency < 15;
+        return $discriminativeefficiency < 15;
     }
 
-    public function  wrap_html_start() {
-        // Horrible Moodle 2.0 wide-content work-around.
-        if (!$this->is_downloading()) {
-            echo html_writer::start_tag('div', array('id' => 'tablecontainer',
-                    'class' => 'statistics-tablecontainer'));
+    /**
+     * Check if the given stats object is an instance of calculated_question_summary.
+     *
+     * @param  \core_question\statistics\questions\calculated $questionstat Stats object
+     * @return bool
+     */
+    protected function is_calculated_question_summary($questionstat) {
+        return $questionstat instanceof calculated_question_summary;
+    }
+
+    /**
+     * Format inputs to represent a range between $min and $max.
+     * This function does not check if $min is less than $max or not.
+     * If both $min and $max are equal to null, this function returns an empty string.
+     *
+     * @param string|null $min The minimum value in the range
+     * @param string|null $max The maximum value in the range
+     * @return string
+     */
+    protected function format_range(string $min = null, string $max = null) {
+        if (is_null($min) && is_null($max)) {
+            return '';
+        } else {
+            $a = new stdClass();
+            $a->min = $min;
+            $a->max = $max;
+
+            return get_string('rangebetween', 'quiz_statistics', $a);
         }
     }
 
-    public function wrap_html_finish() {
-        if (!$this->is_downloading()) {
-            echo html_writer::end_tag('div');
+    /**
+     * Format a number to a localised percentage with specified decimal points.
+     *
+     * @param float $number The number being formatted
+     * @param bool $fraction An indicator for whether the number is a fraction or is already multiplied by 100
+     * @param int $decimals Sets the number of decimal points
+     * @return string
+     */
+    protected function format_percentage(float $number, bool $fraction = true, int $decimals = 2) {
+        $coefficient = $fraction ? 100 : 1;
+        return get_string('percents', 'moodle', format_float($number * $coefficient, $decimals));
+    }
+
+    /**
+     * Format $min and $max to localised percentages and form a string that represents a range between them.
+     * This function does not check if $min is less than $max or not.
+     * If both $min and $max are equal to null, this function returns an empty string.
+     *
+     * @param float|null $min The minimum value of the range
+     * @param float|null $max The maximum value of the range
+     * @param bool $fraction An indicator for whether min and max are a fractions or are already multiplied by 100
+     * @param int $decimals Sets the number of decimal points
+     * @return string A formatted string that represents a range between $min to $max.
+     */
+    protected function format_percentage_range(float $min = null, float $max = null, bool $fraction = true, int $decimals = 2) {
+        if (is_null($min) && is_null($max)) {
+            return '';
+        } else {
+            $min = $min ?: 0;
+            $max = $max ?: 0;
+            return $this->format_range(
+                    $this->format_percentage($min, $fraction, $decimals),
+                    $this->format_percentage($max, $fraction, $decimals)
+            );
         }
     }
 }
diff --git a/mod/quiz/report/statistics/tests/behat/basic.feature b/mod/quiz/report/statistics/tests/behat/basic.feature
new file mode 100644 (file)
index 0000000..62c092b
--- /dev/null
@@ -0,0 +1,97 @@
+@mod @mod_quiz @quiz @quiz_statistics
+Feature: Basic use of the Statistics report
+  In order to see how my students are progressing
+  As a teacher
+  I need to see all their quiz responses
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+      | student2 | Student   | 2        | student2@example.com |
+      | student3 | Student   | 3        | student3@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+      | student2 | C1     | student        |
+      | student3 | C1     | student        |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "questions" exist:
+      | questioncategory | qtype     | name       | questiontext        |
+      | Test questions   | truefalse | Question A | This is question 01 |
+      | Test questions   | truefalse | Question B | This is question 02 |
+      | Test questions   | truefalse | Question C | This is question 03 |
+    And the following "activities" exist:
+      | activity   | name   | course | idnumber |
+      | quiz       | Quiz 1 | C1     | quiz1    |
+    And quiz "Quiz 1" contains the following questions:
+      | question   | page |
+      | Question A | 1    |
+      | Question B | 1    |
+      | Question C | 2    |
+
+  @javascript
+  Scenario: Report works when there are no attempts
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Quiz 1"
+    And I navigate to "Results > Statistics" in current page administration
+    Then I should see "No attempts have been made at this quiz, or all attempts have questions that need manual grading."
+    And I should not see "Statistics for question positions"
+    And "Show chart data" "link" should not exist
+    When user "student1" has attempted "Quiz 1" with responses:
+      | slot | response |
+      |   1  | True     |
+      |   2  | False    |
+      |   3  | False    |
+    And user "student2" has attempted "Quiz 1" with responses:
+      | slot | response |
+      |   1  | True     |
+      |   2  | True     |
+      |   3  | True     |
+    And user "student3" has attempted "Quiz 1" with responses:
+      | slot | response |
+      |   1  | False    |
+      |   2  | False    |
+      |   3  | False    |
+    And I press "Show report"
+    Then I should not see "No attempts have been made at this quiz, or all attempts have questions that need manual grading."
+    And "Show chart data" "link" should exist
+
+    # Question A statistics breakdown.
+    And "1" row "Question name" column of "questionstatistics" table should contain "Question A"
+    And "1" row "Attempts" column of "questionstatistics" table should contain "3"
+    And "1" row "Facility index" column of "questionstatistics" table should contain "66.67 %"
+    And "1" row "Standard deviation" column of "questionstatistics" table should contain "57.74 %"
+    And "1" row "Random guess score" column of "questionstatistics" table should contain "50.00 %"
+    And "1" row "Intended weight" column of "questionstatistics" table should contain "33.33 %"
+    And "1" row "Effective weight" column of "questionstatistics" table should contain "30.90 %"
+    And "1" row "Discrimination index" column of "questionstatistics" table should contain "50.00 %"
+
+    # Question B statistics breakdown.
+    And "2" row "Question name" column of "questionstatistics" table should contain "Question B"
+    And "2" row "Attempts" column of "questionstatistics" table should contain "3"
+    And "2" row "Facility index" column of "questionstatistics" table should contain "33.33 %"
+    And "2" row "Standard deviation" column of "questionstatistics" table should contain "57.74 %"
+    And "2" row "Random guess score" column of "questionstatistics" table should contain "50.00 %"
+    And "2" row "Intended weight" column of "questionstatistics" table should contain "33.33 %"
+    And "2" row "Effective weight" column of "questionstatistics" table should contain "34.55 %"
+    And "2" row "Discrimination index" column of "questionstatistics" table should contain "86.60 %"
+
+    # Question C statistics breakdown.
+    And "3" row "Question name" column of "questionstatistics" table should contain "Question C"
+    And "3" row "Attempts" column of "questionstatistics" table should contain "3"
+    And "3" row "Facility index" column of "questionstatistics" table should contain "33.33 %"
+    And "3" row "Standard deviation" column of "questionstatistics" table should contain "57.74 %"
+    And "3" row "Random guess score" column of "questionstatistics" table should contain "50.00 %"
+    And "3" row "Intended weight" column of "questionstatistics" table should contain "33.33 %"
+    And "3" row "Effective weight" column of "questionstatistics" table should contain "34.55 %"
+    And "3" row "Discrimination index" column of "questionstatistics" table should contain "86.60 %"
+
diff --git a/mod/quiz/report/statistics/tests/statistics_table_test.php b/mod/quiz/report/statistics/tests/statistics_table_test.php
new file mode 100644 (file)
index 0000000..45d0bcb
--- /dev/null
@@ -0,0 +1,95 @@
+<?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/>.
+
+/**
+ * Unit tests for the quiz_statistics_table class.
+ *
+ * @package    quiz_statistics
+ * @category   test
+ * @copyright  2018 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php');
+
+/**
+ * Class quiz_statistics_statistics_table_testcase
+ *
+ * @copyright  2018 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class quiz_statistics_statistics_table_testcase extends advanced_testcase {
+
+    public function test_format_percentage() {
+        $table = new quiz_statistics_table();
+
+        // The format_percentage method is protected. Use Reflection to call the method.
+        $reflector = new ReflectionClass('quiz_statistics_table');
+        $method = $reflector->getMethod('format_percentage');
+        $method->setAccessible(true);
+
+        $this->assertEquals(
+                '84.758 %',
+                $method->invokeArgs($table, [0.847576, true, 3])
+        );
+
+        $this->assertEquals(
+                '84.758 %',
+                $method->invokeArgs($table, [84.7576, false, 3])
+        );
+    }
+
+    public function test_format_percentage_range() {
+        $table = new quiz_statistics_table();
+
+        // The format_percentage_range method is protected. Use Reflection to call the method.
+        $reflector = new ReflectionClass('quiz_statistics_table');
+        $method = $reflector->getMethod('format_percentage_range');
+        $method->setAccessible(true);
+
+        $this->assertEquals(
+                '54.400 % − 84.758 %',
+                $method->invokeArgs($table, [0.544, 0.847576, true, 3])
+        );
+
+        $this->assertEquals(
+                '54.400 % − 84.758 %',
+                $method->invokeArgs($table, [54.4, 84.7576, false, 3])
+        );
+    }
+
+    public function test_format_range() {
+        $table = new quiz_statistics_table();
+
+        // The format_range method is protected. Use Reflection to call the method.
+        $reflector = new ReflectionClass('quiz_statistics_table');
+        $method = $reflector->getMethod('format_range');
+        $method->setAccessible(true);
+
+        $this->assertEquals(
+                '5 − 10',
+                $method->invokeArgs($table, [5, 10])
+        );
+
+        $this->assertEquals(
+                'Some Text − 10',
+                $method->invokeArgs($table, ['Some Text', 10])
+        );
+    }
+}
index e20c17e..b38f227 100644 (file)
@@ -31,15 +31,14 @@ Feature: Attempt a quiz
       | question | page | maxmark |
       | TF1      | 1    |         |
       | TF2      | 1    | 3.0     |
+    And user "student" has attempted "Quiz 1" with responses:
+      | slot | response |
+      |   1  | True     |
+      |   2  | False    |
     When I log in as "student"
     And I am on "Course 1" course homepage
     And I follow "Quiz 1"
-    And I press "Attempt quiz now"
-    And I click on "True" "radio" in the "First question" "question"
-    And I click on "False" "radio" in the "Second question" "question"
-    And I press "Finish attempt ..."
-    And I press "Submit all and finish"
-    And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue"
+    And I follow "Review"
     Then I should see "25.00 out of 100.00"
 
   @javascript
index 0450293..7d5750e 100644 (file)
@@ -561,4 +561,262 @@ class behat_mod_quiz extends behat_question_base {
                 "contains(., {$questionnumberliteral}) and contains(preceding-sibling::h3[1], {$headingliteral})]";
         $this->find('xpath', $xpath);
     }
+
+    /**
+     * Helper used by user_has_attempted_with_responses,
+     * user_has_started_an_attempt_at_quiz_with_details, etc.
+     *
+     * @param TableNode $attemptinfo data table from the Behat step
+     * @return array with two elements, $forcedrandomquestions, $forcedvariants,
+     *      that can be passed to $quizgenerator->create_attempt.
+     */
+    protected function extract_forced_randomisation_from_attempt_info(TableNode $attemptinfo) {
+        global $DB;
+
+        $forcedrandomquestions = [];
+        $forcedvariants = [];
+        foreach ($attemptinfo->getHash() as $slotinfo) {
+            if (empty($slotinfo['slot'])) {
+                throw new ExpectationException('When simulating a quiz attempt, ' .
+                        'the slot column is required.', $this->getSession());
+            }
+
+            if (!empty($slotinfo['actualquestion'])) {
+                $forcedrandomquestions[$slotinfo['slot']] = $DB->get_field('question', 'id',
+                        ['name' => $slotinfo['actualquestion']], MUST_EXIST);
+            }
+
+            if (!empty($slotinfo['variant'])) {
+                $forcedvariants[$slotinfo['slot']] = (int) $slotinfo['variant'];
+            }
+        }
+        return [$forcedrandomquestions, $forcedvariants];
+    }
+
+    /**
+     * Helper used by user_has_attempted_with_responses, user_has_checked_answers_in_their_attempt_at_quiz,
+     * user_has_input_answers_in_their_attempt_at_quiz, etc.
+     *
+     * @param TableNode $attemptinfo data table from the Behat step
+     * @return array of responses that can be passed to $quizgenerator->submit_responses.
+     */
+    protected function extract_responses_from_attempt_info(TableNode $attemptinfo) {
+        $responses = [];
+        foreach ($attemptinfo->getHash() as $slotinfo) {
+            if (empty($slotinfo['slot'])) {
+                throw new ExpectationException('When simulating a quiz attempt, ' .
+                        'the slot column is required.', $this->getSession());
+            }
+            if (!array_key_exists('response', $slotinfo)) {
+                throw new ExpectationException('When simulating a quiz attempt, ' .
+                        'the response column is required.', $this->getSession());
+            }
+            $responses[$slotinfo['slot']] = $slotinfo['response'];
+        }
+        return $responses;
+    }
+
+    /**
+     * Attempt a quiz.
+     *
+     * The first row should be column names:
+     * | slot | actualquestion | variant | response |
+     * The first two of those are required. The others are optional.
+     *
+     * slot           The slot
+     * actualquestion This column is optional, and is only needed if the quiz contains
+     *                random questions. If so, this will let you control which actual
+     *                question gets picked when this slot is 'randomised' at the
+     *                start of the attempt. If you don't specify, then one will be picked
+     *                at random (which might make the reponse meaningless).
+     * variant        This column is similar, and also options. It is only needed if
+     *                the question that ends up in this slot returns something greater
+     *                than 1 for $question->get_num_variants(). Like with actualquestion,
+     *                if you specify a value here it is used the fix the 'random' choice
+     *                made when the quiz is started.
+     * response       The response that was submitted. How this is interpreted depends on
+     *                the question type. It gets passed to
+     *                {@link core_question_generator::get_simulated_post_data_for_question_attempt()}
+     *                and therefore to the un_summarise_response method of the question to decode.
+     *
+     * Then there should be a number of rows of data, one for each question you want to add.
+     * There is no need to supply answers to all questions. If so, other qusetions will be
+     * left unanswered.
+     *
+     * @param string $username the username of the user that will attempt.
+     * @param string $quizname the name of the quiz the user will attempt.
+     * @param TableNode $attemptinfo information about the questions to add, as above.
+     * @Given /^user "([^"]*)" has attempted "([^"]*)" with responses:$/
+     */
+    public function user_has_attempted_with_responses($username, $quizname, TableNode $attemptinfo) {
+        global $DB;
+
+        /** @var mod_quiz_generator $quizgenerator */
+        $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz');
+
+        $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
+        $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
+
+        list($forcedrandomquestions, $forcedvariants) =
+                $this->extract_forced_randomisation_from_attempt_info($attemptinfo);
+        $responses = $this->extract_responses_from_attempt_info($attemptinfo);
+
+        $this->set_user($user);
+
+        $attempt = $quizgenerator->create_attempt($quizid, $user->id,
+                $forcedrandomquestions, $forcedvariants);
+
+        $quizgenerator->submit_responses($attempt->id, $responses, false, true);
+
+        $this->set_user();
+    }
+
+    /**
+     * Start a quiz attempt without answers.
+     *
+     * Then there should be a number of rows of data, one for each question you want to add.
+     * There is no need to supply answers to all questions. If so, other qusetions will be
+     * left unanswered.
+     *
+     * @param string $username the username of the user that will attempt.
+     * @param string $quizname the name of the quiz the user will attempt.
+     * @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*)"$/
+     */
+    public function user_has_started_an_attempt_at_quiz($username, $quizname) {
+        global $DB;
+
+        /** @var mod_quiz_generator $quizgenerator */
+        $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz');
+
+        $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
+        $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
+        $this->set_user($user);
+        $quizgenerator->create_attempt($quizid, $user->id);
+        $this->set_user();
+    }
+
+    /**
+     * Start a quiz attempt without answers.
+     *
+     * The supplied data table for have a row for each slot where you want
+     * to force either which random question was chose, or which random variant
+     * was used, as for {@link user_has_attempted_with_responses()} above.
+     *
+     * @param string $username the username of the user that will attempt.
+     * @param string $quizname the name of the quiz the user will attempt.
+     * @param TableNode $attemptinfo information about the questions to add, as above.
+     * @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*) randomised as follows:"$/
+     */
+    public function user_has_started_an_attempt_at_quiz_with_details($username, $quizname, TableNode $attemptinfo) {
+        global $DB;
+
+        /** @var mod_quiz_generator $quizgenerator */
+        $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz');
+
+        $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
+        $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
+
+        list($forcedrandomquestions, $forcedvariants) =
+                $this->extract_forced_randomisation_from_attempt_info($attemptinfo);
+
+        $this->set_user($user);
+
+        $quizgenerator->create_attempt($quizid, $user->id,
+                $forcedrandomquestions, $forcedvariants);
+
+        $this->set_user();
+    }
+
+    /**
+     * Input answers to particular questions an existing quiz attempt, without
+     * simulating a click of the 'Check' button, if any.
+     *
+     * Then there should be a number of rows of data, with two columns slot and response,
+     * as for {@link user_has_attempted_with_responses()} above.
+     * There is no need to supply answers to all questions. If so, other questions will be
+     * left unanswered.
+     *
+     * @param string $username the username of the user that will attempt.
+     * @param string $quizname the name of the quiz the user will attempt.
+     * @param TableNode $attemptinfo information about the questions to add, as above.
+     * @throws \Behat\Mink\Exception\ExpectationException
+     * @Given /^user "([^"]*)" has input answers in their attempt at quiz "([^"]*)":$/
+     */
+    public function user_has_input_answers_in_their_attempt_at_quiz($username, $quizname, TableNode $attemptinfo) {
+        global $DB;
+
+        /** @var mod_quiz_generator $quizgenerator */
+        $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz');
+
+        $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
+        $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
+
+        $responses = $this->extract_responses_from_attempt_info($attemptinfo);
+
+        $this->set_user($user);
+
+        $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true);
+        $quizgenerator->submit_responses(key($attempts), $responses, false, false);
+
+        $this->set_user();
+    }
+
+    /**
+     * Submit answers to questions an existing quiz attempt, with a simulated click on the 'Check' button.
+     *
+     * This step should only be used with question behaviours that have have
+     * a 'Check' button. Those include Interactive with multiple tires, Immediate feedback
+     * and Immediate feedback with CBM.
+     *
+     * Then there should be a number of rows of data, with two columns slot and response,
+     * as for {@link user_has_attempted_with_responses()} above.
+     * There is no need to supply answers to all questions. If so, other questions will be
+     * left unanswered.
+     *
+     * @param string $username the username of the user that will attempt.
+     * @param string $quizname the name of the quiz the user will attempt.
+     * @param TableNode $attemptinfo information about the questions to add, as above.
+     * @throws \Behat\Mink\Exception\ExpectationException
+     * @Given /^user "([^"]*)" has checked answers in their attempt at quiz "([^"]*)":$/
+     */
+    public function user_has_checked_answers_in_their_attempt_at_quiz($username, $quizname, TableNode $attemptinfo) {
+        global $DB;
+
+        /** @var mod_quiz_generator $quizgenerator */
+        $quizgenerator = behat_util::get_data_generator()->get_plugin_generator('mod_quiz');
+
+        $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
+        $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
+
+        $responses = $this->extract_responses_from_attempt_info($attemptinfo);
+
+        $this->set_user($user);
+
+        $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true);
+        $quizgenerator->submit_responses(key($attempts), $responses, true, false);
+
+        $this->set_user();
+    }
+
+    /**
+     * Finish an existing quiz attempt.
+     *
+     * @param string $username the username of the user that will attempt.
+     * @param string $quizname the name of the quiz the user will attempt.
+     * @Given /^user "([^"]*)" has finished an attempt at quiz "([^"]*)"$/
+     */
+    public function user_has_finished_an_attempt_at_quiz($username, $quizname) {
+        global $DB;
+
+        $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
+        $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
+
+        $this->set_user($user);
+
+        $attempts = quiz_get_user_attempts($quizid, $user->id, 'unfinished', true);
+        $attemptobj = quiz_attempt::create(key($attempts));
+        $attemptobj->process_finish(time(), true);
+
+        $this->set_user();
+    }
 }
index 686e05f..7e87610 100644 (file)
@@ -30,19 +30,15 @@ Feature: Set a quiz to be marked complete when the student uses all attempts all
     And quiz "Test quiz name" contains the following questions:
       | question       | page |
       | First question | 1    |
+    And user "student1" has attempted "Test quiz name" with responses:
+      | slot | response |
+      |   1  | False    |
 
   Scenario: student1 uses up both attempts without passing
     When I log in as "student1"
     And I am on "Course 1" course homepage
     And the "Test quiz name" "quiz" activity with "auto" completion should be marked as not complete
     And I follow "Test quiz name"
-    And I press "Attempt quiz now"
-    And I set the field "False" to "1"
-    And I press "Finish attempt ..."
-    And I press "Submit all and finish"
-    And I am on "Course 1" course homepage
-    And the "Test quiz name" "quiz" activity with "auto" completion should be marked as not complete
-    And I follow "Test quiz name"
     And I press "Re-attempt quiz"
     And I set the field "False" to "1"
     And I press "Finish attempt ..."
index 6689683..668c563 100644 (file)
@@ -35,11 +35,9 @@ Feature: Set a quiz to be marked complete when the student passes
     When I log in as "student1"
     And I am on "Course 1" course homepage
     And the "Test quiz name" "quiz" activity with "auto" completion should be marked as not complete
-    And I follow "Test quiz name"
-    And I press "Attempt quiz now"
-    And I set the field "True" to "1"
-    And I press "Finish attempt ..."
-    And I press "Submit all and finish"
+    And user "student1" has attempted "Test quiz name" with responses:
+      | slot | response |
+      |   1  | True     |
     And I am on "Course 1" course homepage
     Then "Completed: Test quiz name" "icon" should exist in the "li.modtype_quiz" "css_element"
     And I log out
index d4e84f7..a410d0e 100644 (file)
@@ -28,18 +28,17 @@ Feature: Preview a quiz as a teacher
       | question | page | maxmark |
       | TF1      | 1    |         |
       | TF2      | 1    | 3.0     |
-    And I log in as "teacher"
-    And I am on "Course 1" course homepage
+    And user "teacher" has attempted "Quiz 1" with responses:
+      | slot | response |
+      |   1  | True     |
+      |   2  | False    |
 
   @javascript
   Scenario: Preview a quiz
+    When I log in as "teacher"
+    And I am on "Course 1" course homepage
     When I follow "Quiz 1"
-    And I press "Preview quiz now"
-    And I click on "True" "radio" in the "First question" "question"
-    And I click on "False" "radio" in the "Second question" "question"
-    And I press "Finish attempt ..."
-    And I press "Submit all and finish"
-    And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue"
+    And I follow "Review"
     Then I should see "25.00 out of 100.00"
     And I follow "Finish review"
     And "Review" "link" in the "Preview" "table_row" should be visible
index 8fd0849..1337e97 100644 (file)
@@ -32,17 +32,12 @@ Feature: Quiz reset
     And quiz "Test quiz name" contains the following questions:
       | question | page |
       | TF1      | 1    |
+    And user "student1" has attempted "Test quiz name" with responses:
+      | slot | response |
+      |   1  | True     |
 
   Scenario: Use course reset to clear all attempt data
-    When I log in as "student1"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz name"
-    And I press "Attempt quiz now"
-    And I set the field "True" to "1"
-    And I press "Finish attempt ..."
-    And I press "Submit all and finish"
-    And I log out
-    And I log in as "teacher1"
+    When I log in as "teacher1"
     And I am on "Course 1" course homepage
     And I navigate to "Reset" in current page administration
     And I set the following fields to these values:
index db1703f..5b18dec 100644 (file)
@@ -95,4 +95,79 @@ class mod_quiz_generator extends testing_module_generator {
 
         return parent::create_instance($record, (array)$options);
     }
+
+    /**
+     * Create a quiz attempt for a particular user at a particular course.
+     *
+     * Currently this method can only create a first attempt for each
+     * user at each quiz. TODO remove this limitation.
+     *
+     * @param int $quizid the quiz id (from the mdl_quit table, not cmid).
+     * @param int $userid the user id.
+     * @param array $forcedrandomquestions slot => questionid. Optional,
+     *      used with random questions, to control which one is 'randomly' selected in that slot.
+     * @param array $forcedvariants slot => variantno. Optional. Optional,
+     *      used with question where get_num_variants is > 1, to control which
+     *      variants is 'randomly' selected.
+     * @return stdClass the new attempt.
+     */
+    public function create_attempt($quizid, $userid, array $forcedrandomquestions = [],
+            array $forcedvariants = []) {
+        // Build quiz object and load questions.
+        $quizobj = quiz::create($quizid, $userid);
+
+        if (quiz_get_user_attempts($quizid, $userid, 'all', true)) {
+            throw new coding_exception('mod_quiz_generator is currently limited to only ' .
+                    'be able to create one attempt for each user. (This should be fixed.)');
+        }
+
+        return quiz_prepare_and_start_new_attempt($quizobj, 1, null, false,
+                $forcedrandomquestions, $forcedvariants);
+    }
+
+    /**
+     * Submit responses to a quiz attempt.
+     *
+     * To be realistic, you should ensure that $USER is set to the user whose attempt
+     * it is before calling this.
+     *
+     * @param int $attemptid the id of the attempt which is being
+     * @param array $responses array responses to submit. See description on
+     *      {@link core_question_generator::get_simulated_post_data_for_questions_in_usage()}.
+     * @param bool $checkbutton if simulate a click on the check button for each question, else simulate save.
+     *      This should only be used with behaviours that have a check button.
+     * @param bool $finishattempt if true, the attempt will be submitted.
+     */
+    public function submit_responses($attemptid, array $responses, $checkbutton, $finishattempt) {
+        $questiongenerator = $this->datagenerator->get_plugin_generator('core_question');
+
+        $attemptobj = quiz_attempt::create($attemptid);
+
+        $postdata = $questiongenerator->get_simulated_post_data_for_questions_in_usage(
+                $attemptobj->get_question_usage(), $responses, $checkbutton);
+
+        $attemptobj->process_submitted_actions(time(), false, $postdata);
+
+        // Bit if a hack for interactive behaviour.
+        // TODO handle this in a more plugin-friendly way.
+        if ($checkbutton) {
+            $postdata = [];
+            foreach ($responses as $slot => $notused) {
+                $qa = $attemptobj->get_question_attempt($slot);
+                if ($qa->get_behaviour() instanceof qbehaviour_interactive && $qa->get_behaviour()->is_try_again_state()) {
+                    $postdata[$qa->get_control_field_name('sequencecheck')] = (string)$qa->get_sequence_check_count();
+                    $postdata[$qa->get_flag_field_name()] = (string)(int)$qa->is_flagged();
+                    $postdata[$qa->get_behaviour_field_name('tryagain')] = 1;
+                }
+            }
+
+            if ($postdata) {
+                $attemptobj->process_submitted_actions(time(), false, $postdata);
+            }
+        }
+
+        if ($finishattempt) {
+            $attemptobj->process_finish(time(), false);
+        }
+    }
 }
index 9986d30..23d9ec3 100644 (file)
@@ -66,9 +66,9 @@ class mod_quiz_reportlib_testcase extends advanced_testcase {
         $quiz->sumgrades = 10;
         $quiz->decimalpoints = 2;
 
-        $this->assertEquals('12.34567%',
+        $this->assertEquals('12.34567 %',
             quiz_report_scale_summarks_as_percentage(1.234567, $quiz, false));
-        $this->assertEquals('12.35%',
+        $this->assertEquals('12.35 %',
             quiz_report_scale_summarks_as_percentage(1.234567, $quiz, true));
         $this->assertEquals('-',
             quiz_report_scale_summarks_as_percentage('-', $quiz, true));
index 3f3ac9e..1351082 100644 (file)
@@ -88,14 +88,14 @@ class workshop_edit_numerrors_strategy_form extends workshop_edit_strategy_form
         $percents = array();
         $percents[''] = '';
         for ($i = 100; $i >= 0; $i--) {
-            $percents[$i] = get_string('percents', 'workshopform_numerrors', $i);
+            $percents[$i] = get_string('percents', 'moodle', $i);
         }
-        $mform->addElement('static', 'mappingzero', 0, get_string('percents', 'workshopform_numerrors', 100));
+        $mform->addElement('static', 'mappingzero', 0, get_string('percents', 'moodle', 100));
         for ($i = 1; $i <= $totalweight; $i++) {
             $selects = array();
             $selects[] = $mform->createElement('select', 'map__idx_'.$i, $i, $percents);
             $selects[] = $mform->createElement('static', 'mapdefault__idx_'.$i, '',
-                                        get_string('percents', 'workshopform_numerrors', floor(100 - $i * 100 / $totalweight)));
+                                        get_string('percents', 'moodle', floor(100 - $i * 100 / $totalweight)));
             $mform->addGroup($selects, 'grademapping'.$i, $i, array(' '), false);
             $mform->setDefault('map__idx_'.$i, '');
         }
index dec3aa2..7af1a9d 100644 (file)
@@ -38,7 +38,6 @@ $string['grade1'] = 'Word for the success';
 $string['grade1default'] = 'Yes';
 $string['maperror'] = 'Weighted number of errors is less than or equals';
 $string['mapgrade'] = 'Grade for submission';
-$string['percents'] = '{$a} %';
 $string['pluginname'] = 'Number of errors';
 $string['privacy:metadata'] = 'The Number of errors plugin only stores the details of the assessment form. Actual personal data of how the form has been filled are stored by the Workshop module itself and are attached to exported assessments.';
 $string['privacy:export:metadata:map'] = 'If the weighted number of errors reaches {$a->nonegative} then the grade is {$a->grade} percents.';
index 6f534d6..b58f0fc 100644 (file)
@@ -65,7 +65,7 @@ class qbehaviour_interactive extends question_behaviour_with_multiple_tries {
     /**
      * @return bool are we are currently in the try_again state.
      */
-    protected function is_try_again_state() {
+    public function is_try_again_state() {
         $laststep = $this->qa->get_last_step();
         return $this->qa->get_state()->is_active() && $laststep->has_behaviour_var('submit') &&
                 $laststep->has_behaviour_var('_triesleft');
index 592c126..29235fa 100644 (file)
@@ -39,12 +39,6 @@ class all_calculated_for_qubaid_condition {
     /** @var int Time after which statistics are automatically recomputed. */
     const TIME_TO_CACHE = 900; // 15 minutes.
 
-    /**
-     * The limit of rows of sub-question and variants rows to display on main page of report before switching to showing min,
-     * median and max variants.
-     */
-    const SUBQ_AND_VARIANT_ROW_LIMIT = 10;
-
     /**
      * @var object[]
      */
@@ -311,42 +305,6 @@ class all_calculated_for_qubaid_condition {
         return $errors;
     }
 
-    /**
-     * Are there too many rows of sub-questions and / or variant rows.
-     *
-     * @param array $rows the rows we intend to add.
-     * @return bool Are there too many?
-     */
-    protected function too_many_subq_and_or_variant_rows($rows) {
-        return (count($rows) > static::SUBQ_AND_VARIANT_ROW_LIMIT);
-    }
-
-    /**
-     * From a number of calculated instances find the three instances with min, median and maximum facility index values.
-     *
-     * @param calculated[] $questionstats The stats from which to find the ones with minimum, median and maximum facility index.
-     * @return calculated[] 3 stat objects with minimum, median and maximum facility index.
-     */
-    protected function find_min_median_and_max_facility_stats_objects($questionstats) {
-        $facilities = array();
-        foreach ($questionstats as $key => $questionstat) {
-            $facilities[$key] = (float)$questionstat->facility;
-        }
-        asort($facilities);
-        $facilitykeys = array_keys($facilities);
-        $keyformin = $facilitykeys[0];
-        $keyformedian = $facilitykeys[(int)(round(count($facilitykeys) / 2) - 1)];
-        $keyformax = $facilitykeys[count($facilitykeys) - 1];
-        $toreturn = array();
-        foreach (array($keyformin => 'minimumfacility',
-                       $keyformedian => 'medianfacility',
-                       $keyformax => 'maximumfacility') as $key => $stringid) {
-            $questionstats[$key]->minmedianmaxnotice = $stringid;
-            $toreturn[] = $questionstats[$key];
-        }
-        return $toreturn;
-    }
-
     /**
      * Return all stats for variants of question in slot $slot.
      *
@@ -405,39 +363,61 @@ class all_calculated_for_qubaid_condition {
      *
      * @param int $slot the slot no
      * @param bool $limited limit number of variants and sub-questions displayed?
-     * @return calculated|calculated_for_subquestion[] stats to display
+     * @return calculated|calculated_for_subquestion|calculated_question_summary[] stats to display
      */
     protected function all_subq_and_variant_stats_for_slot($slot, $limited) {
         // Random question in this slot?
         if ($this->for_slot($slot)->get_sub_question_ids()) {
+            $toreturn = array();
+
             if ($limited) {
-                $subqvariantstats = $this->all_subq_variants_for_one_slot($slot);
-                if ($this->too_many_subq_and_or_variant_rows($subqvariantstats)) {
-                    // Too many variants from randomly selected questions.
-                    return $this->find_min_median_and_max_facility_stats_objects($subqvariantstats);
+                $randomquestioncalculated = $this->for_slot($slot);
+
+                if ($subqvariantstats = $this->all_subq_variants_for_one_slot($slot)) {
+                    // There are some variants from randomly selected questions.
+                    // If we're showing a limited view of the statistics then add a question summary stat
+                    // rather than a stat for each subquestion.
+                    $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqvariantstats);
+
+                    $toreturn = array_merge($toreturn, [$summarystat]);
                 }
-                $subqstats = $this->all_subqs_for_one_slot($slot);
-                if ($this->too_many_subq_and_or_variant_rows($subqstats)) {
-                    // Too many randomly selected questions.
-                    return $this->find_min_median_and_max_facility_stats_objects($subqstats);
+
+                if ($subqstats = $this->all_subqs_for_one_slot($slot)) {
+                    // There are some randomly selected questions.
+                    // If we're showing a limited view of the statistics then add a question summary stat
+                    // rather than a stat for each subquestion.
+                    $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqstats);
+
+                    $toreturn = array_merge($toreturn, [$summarystat]);
                 }
-            }
-            $toreturn = array();
-            $displaynumber = 1;
-            foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
-                $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid);
-                if ($variants = $this->for_subq($subqid)->get_variants()) {
-                    foreach ($variants as $variant) {
-                        $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant);
+
+                foreach ($toreturn as $index => $calculated) {
+                    $calculated->subqdisplayorder = $index;
+                }
+            } else {
+                $displaynumber = 1;
+                foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
+                    $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid);
+                    if ($variants = $this->for_subq($subqid)->get_variants()) {
+                        foreach ($variants as $variant) {
+                            $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant);
+                        }
                     }
+                    $displaynumber++;
                 }
-                $displaynumber++;
             }
+
             return $toreturn;
         } else {
             $variantstats = $this->all_variant_stats_for_one_slot($slot);
-            if ($limited && $this->too_many_subq_and_or_variant_rows($variantstats)) {
-                return $this->find_min_median_and_max_facility_stats_objects($variantstats);
+            if ($limited && $variantstats) {
+                $variantquestioncalculated = $this->for_slot($slot);
+
+                // If we're showing a limited view of the statistics then add a question summary stat
+                // rather than a stat for each variation.
+                $summarystat = $this->make_new_calculated_question_summary_stat($variantquestioncalculated, $variantstats);
+
+                return [$summarystat];
             } else {
                 return $variantstats;
             }
@@ -460,4 +440,21 @@ class all_calculated_for_qubaid_condition {
         $slotstat->subqdisplayorder = $displaynumber;
         return $slotstat;
     }
+
+    /**
+     * Create a summary calculated object for a calculated question. This is used as a placeholder
+     * to indicate that a calculated question has sub questions or variations to show rather than listing each
+     * subquestion or variation directly.
+     *
+     * @param  calculated $randomquestioncalculated The calculated instance for the random question slot.
+     * @param  calculated[] $subquestionstats The instances of the calculated stats of the questions that are being summarised.
+     * @return calculated_question_summary
+     */
+    protected function make_new_calculated_question_summary_stat($randomquestioncalculated, $subquestionstats) {
+        $question = $randomquestioncalculated->question;
+        $slot = $randomquestioncalculated->slot;
+        $calculatedsummary = new calculated_question_summary($question, $slot, $subquestionstats);
+
+        return $calculatedsummary;
+    }
 }
diff --git a/question/classes/statistics/questions/calculated_question_summary.php b/question/classes/statistics/questions/calculated_question_summary.php
new file mode 100644 (file)
index 0000000..982baf3
--- /dev/null
@@ -0,0 +1,184 @@
+<?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/>.
+
+/**
+ * Question statistics calculations class. Used in the quiz statistics report.
+ *
+ * @package    core_question
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_question\statistics\questions;
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class calculated_question_summary
+ *
+ * This class is used to indicate the statistics for a random question slot should
+ * be rendered with a link to a summary of the displayed questions.
+ *
+ * It's used in the limited view of the statistics calculation in lieu of adding
+ * the stats for each subquestion individually.
+ *
+ * @copyright 2018 Ryan Wyllie <ryan@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class calculated_question_summary extends calculated {
+
+    /**
+     * @var int only set immediately before display in the table. The order of display in the table.
+     */
+    public $subqdisplayorder;
+
+    /**
+     * @var calculated[] The instances storing the calculated stats of the questions that are being summarised.
+     */
+    protected $subqstats;
+
+    /**
+     * calculated_question_summary constructor.
+     *
+     * @param \stdClass $question
+     * @param int $slot
+     * @param calculated[] $subqstats The instances of the calculated stats of the questions that are being summarised.
+     */
+    public function __construct($question, $slot, $subqstats) {
+        parent::__construct($question, $slot);
+
+        $this->subqstats = $subqstats;
+        $this->subquestions = implode(',', array_column($subqstats, 'questionid'));
+    }
+
+    /**
+     * This is a summary stat so never breakdown by variant.
+     *
+     * @return bool
+     */
+    public function break_down_by_variant() {
+        return false;
+    }
+
+    /**
+     * Returns the minimum and maximum values of the given attribute in the summarised calculated stats.
+     *
+     * @param string $attribute The attribute that we are looking for its extremums.
+     * @return array An array of [min,max]
+     */
+    public function get_min_max_of($attribute) {
+        $getmethod = 'get_min_max_of_' . $attribute;
+        if (method_exists($this, $getmethod)) {
+            return $this->$getmethod();
+        } else {
+            $min = $max = null;
+            $set = false;
+
+            // We cannot simply use min or max functions because, in theory, some attributes might be non-scalar.
+            foreach (array_column($this->subqstats, $attribute) as $value) {
+                if (is_scalar($value) || is_null($value)) {
+                    if (!$set) {    // It is not good enough to check if (!isset($min)),
+                                    // because $min might have been set to null in an earlier iteration.
+                        $min = $value;
+                        $max = $value;
+                        $set = true;
+                    }
+
+                    $min  = $this->min($min, $value);
+                    $max  = $this->max($max, $value);
+                }
+            }
+
+            return [$min, $max];
+        }
+    }
+
+    /**
+     * Returns the minimum and maximum values of the standard deviation in the summarised calculated stats.
+     * @return array An array of [min,max]
+     */
+    protected function get_min_max_of_sd() {
+        $min = $max = null;
+        $set = false;
+
+        foreach ($this->subqstats as $subqstat) {
+            if (isset($subqstat->sd) && $subqstat->maxmark) {
+                $value = $subqstat->sd / $subqstat->maxmark;
+            } else {
+                $value = null;
+            }
+
+            if (!$set) {    // It is not good enough to check if (!isset($min)),
+                            // because $min might have been set to null in an earlier iteration.
+                $min = $value;
+                $max = $value;
+                $set = true;
+            }
+
+            $min = $this->min($min, $value);
+            $max = $this->max($max, $value);
+        }
+
+        return [$min, $max];
+    }
+
+    /**
+     * Find higher value.
+     * A zero value is almost considered equal to zero in comparisons. The only difference is that when being compared to zero,
+     * zero is higher than null.
+     *
+     * @param float|null $value1
+     * @param float|null $value2
+     * @return float|null
+     */
+    protected function max(float $value1 = null, float $value2 = null) {
+        $temp1 = $value1 ?: 0;
+        $temp2 = $value2 ?: 0;
+
+        $tempmax = max($temp1, $temp2);
+
+        if (!$tempmax && $value1 !== 0 && $value2 !== 0) {
+            $max = null;
+        } else {
+            $max = $tempmax;
+        }
+
+        return $max;
+    }
+
+    /**
+     * Find lower value.
+     * A zero value is almost considered equal to zero in comparisons. The only difference is that when being compared to zero,
+     * zero is lower than null.
+     *
+     * @param float|null $value1
+     * @param float|null $value2
+     * @return mixed|null
+     */
+    protected function min(float $value1 = null, float $value2 = null) {
+        $temp1 = $value1 ?: 0;
+        $temp2 = $value2 ?: 0;
+
+        $tempmin = min($temp1, $temp2);
+
+        if (!$tempmin && $value1 !== 0 && $value2 !== 0) {
+            $min = null;
+        } else {
+            $min = $tempmin;
+        }
+
+        return $min;
+    }
+}
diff --git a/question/tests/calculated_question_summary_test.php b/question/tests/calculated_question_summary_test.php
new file mode 100644 (file)
index 0000000..b52c06f
--- /dev/null
@@ -0,0 +1,162 @@
+<?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/>.
+
+/**
+ * Unit tests for the calculated_random_question_summary class.
+ *
+ * @package    core_question
+ * @category   test
+ * @copyright  2018 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_question\statistics\questions\calculated_question_summary;
+
+/**
+ * Class core_question_calculated_question_summary_testcase
+ *
+ * @copyright  2018 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_question_calculated_question_summary_testcase extends advanced_testcase {
+
+    /**
+     * Provider for test_get_min_max_of.
+     *
+     * @return array
+     */
+    public function get_min_max_provider() {
+        return [
+            'negative number and null' => [
+                [
+                    (object)['questionid' => 1, 'index' => 2],
+                    (object)['questionid' => 2, 'index' => -7],
+                    (object)['questionid' => 3, 'index' => null],
+                    (object)['questionid' => 4, 'index' => 12],
+                ],
+                [-7, 12]
+            ],
+            'null and negative number' => [
+                [
+                    (object)['questionid' => 1, 'index' => 2],
+                    (object)['questionid' => 2, 'index' => null],
+                    (object)['questionid' => 3, 'index' => -7],
+                    (object)['questionid' => 4, 'index' => 12],
+                ],
+                [-7, 12]
+            ],
+            'negative number and null as maximum' => [
+                [
+                    (object)['questionid' => 1, 'index' => -2],
+                    (object)['questionid' => 2, 'index' => null],
+                    (object)['questionid' => 3, 'index' => -7],
+                ],
+                [-7, null]
+            ],
+            'zero and null' => [
+                [
+                    (object)['questionid' => 1, 'index' => 2],
+                    (object)['questionid' => 2, 'index' => 0],
+                    (object)['questionid' => 3, 'index' => null],
+                    (object)['questionid' => 4, 'index' => 12],
+                ],
+                [0, 12]
+            ],
+            'null as minimum' => [
+                [
+                    (object)['questionid' => 1, 'index' => 2],
+                    (object)['questionid' => 2, 'index' => null],
+                    (object)['questionid' => 3, 'index' => 12],
+                ],
+                [null, 12]
+            ],
+            'null and null' => [
+                [
+                    (object)['questionid' => 1, 'index' => 2],
+                    (object)['questionid' => 2, 'index' => null],
+                    (object)['questionid' => 3, 'index' => null],
+                ],
+                [null, 2]
+            ],
+        ];
+    }
+
+    /**
+     * Unit test for get_min_max_of() method.
+     *
+     * @dataProvider get_min_max_provider
+     */
+    public function test_get_min_max_of($subqstats, $expected) {
+        $calculatedsummary = new calculated_question_summary(null, null, $subqstats);
+        $res = $calculatedsummary->get_min_max_of('index');
+        $this->assertEquals($expected, $res);
+    }
+
+    /**
+     * Provider for test_get_min_max_of.
+     *
+     * @return array
+     */
+    public function get_sd_min_max_provider() {
+        return [
+            'null and number' => [
+                [
+                    (object)['questionid' => 1, 'sd' => 0.2, 'maxmark' => 0.5],
+                    (object)['questionid' => 2, 'sd' => null, 'maxmark' => 1],
+                    (object)['questionid' => 3, 'sd' => 0.1049, 'maxmark' => 1],
+                    (object)['questionid' => 4, 'sd' => 0.12, 'maxmark' => 1],
+                ],
+                [null, 0.4]
+            ],
+            'null and zero' => [
+                [
+                    (object)['questionid' => 1, 'sd' => 0.2, 'maxmark' => 0.5],
+                    (object)['questionid' => 2, 'sd' => null, 'maxmark' => 1],
+                    (object)['questionid' => 3, 'sd' => 0, 'maxmark' => 1],
+                    (object)['questionid' => 4, 'sd' => 0.12, 'maxmark' => 1],
+                ],
+                [0, 0.4]
+            ],
+            'zero mark' => [
+                [
+                    (object)['questionid' => 1, 'sd' => 0.2, 'maxmark' => 0],
+                    (object)['questionid' => 2, 'sd' => 0.1049, 'maxmark' => 1],
+                ],
+                [null, 0.1049]
+            ],
+            'nonzero and nonzero' => [
+                [
+                    (object)['questionid' => 1, 'sd' => 0.2, 'maxmark' => 0.5],
+                    (object)['questionid' => 2, 'sd' => 0.7, 'maxmark' => 2],
+                ],
+                [0.35, 0.4]
+            ],
+        ];
+    }
+
+    /**
+     * Unit test for get_min_max_of_sd() method.
+     *
+     * @dataProvider get_sd_min_max_provider
+     */
+    public function test_get_min_max_of_sd($subqstats, $expected) {
+        $calculatedsummary = new calculated_question_summary(null, null, $subqstats);
+        $res = $calculatedsummary->get_min_max_of('sd');
+        $this->assertEquals($expected, $res);
+    }
+}
index bdbcca5..4d09630 100644 (file)
@@ -154,4 +154,71 @@ class core_question_generator extends component_generator_base {
 
         return array($category, $course, $qcat, $questions);
     }
+
+    /**
+     * This method can construct what the post data would be to simulate a user submitting
+     * responses to a number of questions within a question usage.
+     *
+     * In the responses array, the array keys are the slot numbers for which a response will
+     * be submitted. You can submit a response to any number of responses within the usage.
+     * There is no need to do them all. The values are a string representation of the response.
+     * The exact meaning of that depends on the particular question type. These strings
+     * are passed to the un_summarise_response method of the question to decode.
+     *
+     * @param question_usage_by_activity $quba the question usage.
+     * @param array $responses the resonses to submit, in the format described above.
+     * @param bool $checkbutton if simulate a click on the check button for each question, else simulate save.
+     *      This should only be used with behaviours that have a check button.
+     * @return array that can be passed to methods like $quba->process_all_actions as simulated POST data.
+     */
+    public function get_simulated_post_data_for_questions_in_usage(
+            question_usage_by_activity $quba, array $responses, $checkbutton) {
+        $postdata = [];
+
+        foreach ($responses as $slot => $responsesummary) {
+            $postdata += $this->get_simulated_post_data_for_question_attempt(
+                    $quba->get_question_attempt($slot), $responsesummary, $checkbutton);
+        }
+
+        return $postdata;
+    }
+
+    /**
+     * This method can construct what the post data would be to simulate a user submitting
+     * responses to one particular question attempt.
+     *
+     * The $responsesummary is a string representation of the response to be submitted.
+     * The exact meaning of that depends on the particular question type. These strings
+     * are passed to the un_summarise_response method of the question to decode.
+     *
+     * @param question_attempt $qa the question attempt for which we are generating POST data.
+     * @param string $responsesummary a textual summary of the response, as described above.
+     * @param bool $checkbutton if simulate a click on the check button, else simulate save.
+     *      This should only be used with behaviours that have a check button.
+     * @return array the simulated post data that can be passed to $quba->process_all_actions.
+     */
+    public function get_simulated_post_data_for_question_attempt(
+            question_attempt $qa, $responsesummary, $checkbutton) {
+
+        $question = $qa->get_question();
+        if (!$question instanceof question_with_responses) {
+            return [];
+        }
+
+        $postdata = [];
+        $postdata[$qa->get_control_field_name('sequencecheck')] = (string)$qa->get_sequence_check_count();
+        $postdata[$qa->get_flag_field_name()] = (string)(int)$qa->is_flagged();
+
+        $response = $question->un_summarise_response($responsesummary);
+        foreach ($response as $name => $value) {
+            $postdata[$qa->get_qt_field_name($name)] = (string)$value;
+        }
+
+        // TODO handle behaviour variables better than this.
+        if ($checkbutton) {
+            $postdata[$qa->get_behaviour_field_name('submit')] = 1;
+        }
+
+        return $postdata;
+    }
 }
index 0e82bd3..73466bf 100644 (file)
@@ -89,6 +89,14 @@ class qtype_essay_question extends question_with_responses {
         }
     }
 
+    public function un_summarise_response(string $summary) {
+        if (!empty($summary)) {
+            return ['answer' => text_to_html($summary)];
+        } else {
+            return [];
+        }
+    }
+
     public function get_correct_response() {
         return null;
     }
index fa22cdd..c30c0b2 100644 (file)
@@ -73,6 +73,10 @@ class qtype_missingtype_question extends question_definition
         return null;
     }
 
+    public function un_summarise_response(string $response) {
+        return [];
+    }
+
     public function classify_response(array $response) {
         return array();
     }
index c69f4d0..79733d4 100644 (file)
@@ -85,6 +85,20 @@ class qtype_numerical_question extends question_graded_automatically {
         return $resp;
     }
 
+    public function un_summarise_response(string $summary) {
+        if ($this->has_separate_unit_field()) {
+            throw new coding_exception('Sorry, but at the moment un_summarise_response cannot handle the
+                has_separate_unit_field case for numerical questions.
+                    If you need this, you will have to implement it yourself.');
+        }
+
+        if (!empty($summary)) {
+            return ['answer' => $summary];
+        } else {
+            return [];
+        }
+    }
+
     public function is_gradable_response(array $response) {
         return array_key_exists('answer', $response) &&
                 ($response['answer'] || $response['answer'] === '0' || $response['answer'] === 0);
index 32dbcf8..6dfc5db 100644 (file)
@@ -505,11 +505,21 @@ interface question_manually_gradable {
 
     /**
      * Produce a plain text summary of a response.
-     * @param $response a response, as might be passed to {@link grade_response()}.
+     * @param array $response a response, as might be passed to {@link grade_response()}.
      * @return string a plain text summary of that response, that could be used in reports.
      */
     public function summarise_response(array $response);
 
+    /**
+     * If possible, construct a response that could have lead to the given
+     * response summary. This is basically the opposite of {@link summarise_response()}
+     * but it is intended only to be used for testing.
+     *
+     * @param string $summary a string, which might have come from summarise_response
+     * @return array a response that could have lead to that.
+     */
+    public function un_summarise_response(string $summary);
+
     /**
      * Categorise the student's response according to the categories defined by
      * get_possible_responses.
@@ -641,6 +651,11 @@ abstract class question_with_responses extends question_definition
     public function is_gradable_response(array $response) {
         return $this->is_complete_response($response);
     }
+
+    public function un_summarise_response(string $summary) {
+        throw new coding_exception('This question type (' . get_class($this) .
+                ' does not implement the un_summarise_response testing method.');
+    }
 }
 
 
index e0f4b40..a9de53b 100644 (file)
@@ -57,6 +57,14 @@ class qtype_shortanswer_question extends question_graded_by_strategy
         }
     }
 
+    public function un_summarise_response(string $summary) {
+        if (!empty($summary)) {
+            return ['answer' => $summary];
+        } else {
+            return [];
+        }
+    }
+
     public function is_complete_response(array $response) {
         return array_key_exists('answer', $response) &&
                 ($response['answer'] || $response['answer'] === '0');
index d3796a6..05d1d69 100644 (file)
@@ -59,6 +59,16 @@ class qtype_truefalse_question extends question_graded_automatically {
         }
     }
 
+    public function un_summarise_response(string $summary) {
+        if ($summary === get_string('true', 'qtype_truefalse')) {
+            return ['answer' => '1'];
+        } else if ($summary === get_string('false', 'qtype_truefalse')) {
+            return ['answer' => '0'];
+        } else {
+            return [];
+        }
+    }
+
     public function classify_response(array $response) {
         if (!array_key_exists('answer', $response)) {
             return array($this->id => question_classified_response::no_response());