Merge branch 'MDL-41760-master-v8' of https://github.com/jamiepratt/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 19 Mar 2014 01:14:00 +0000 (02:14 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 19 Mar 2014 01:14:00 +0000 (02:14 +0100)
Conflicts:
version.php

29 files changed:
lib/db/install.xml
lib/db/upgrade.php
mod/quiz/report/statistics/lang/en/quiz_statistics.php
mod/quiz/report/statistics/report.php
mod/quiz/report/statistics/statistics_form.php
mod/quiz/report/statistics/statistics_question_table.php
mod/quiz/report/statistics/tests/fixtures/qstats00.csv
mod/quiz/report/statistics/tests/fixtures/questions01.csv [new file with mode: 0644]
mod/quiz/report/statistics/tests/fixtures/questions02.csv [new file with mode: 0644]
mod/quiz/report/statistics/tests/fixtures/quizzes.csv
mod/quiz/report/statistics/tests/fixtures/responsecounts00.csv
mod/quiz/report/statistics/tests/fixtures/responsecounts01.csv [new file with mode: 0644]
mod/quiz/report/statistics/tests/fixtures/responsecounts02.csv [new file with mode: 0644]
mod/quiz/report/statistics/tests/fixtures/steps01.csv [new file with mode: 0644]
mod/quiz/report/statistics/tests/fixtures/steps02.csv [new file with mode: 0644]
mod/quiz/report/statistics/tests/stats_from_steps_walkthrough_test.php
mod/quiz/tests/attempt_walkthrough_from_csv_test.php
question/behaviour/behaviourbase.php
question/behaviour/interactivecountback/behaviour.php
question/classes/statistics/responses/analyser.php
question/classes/statistics/responses/analysis_for_actual_response.php
question/classes/statistics/responses/analysis_for_class.php
question/classes/statistics/responses/analysis_for_question.php
question/classes/statistics/responses/analysis_for_question_all_tries.php [new file with mode: 0644]
question/classes/statistics/responses/analysis_for_subpart.php
question/engine/datalib.php
question/engine/questionattempt.php
question/engine/statisticslib.php
version.php

index c8eef76..e5ab4c8 100644 (file)
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="hashcode" TYPE="char" LENGTH="40" NOTNULL="true" SEQUENCE="false" COMMENT="sha1 hash of serialized qubaids_condition class. Unique for every combination of class name and property."/>
+        <FIELD NAME="whichtries" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="questionid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="variant" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="subqid" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="aid" TYPE="char" LENGTH="100" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="response" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
-        <FIELD NAME="rcount" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="credit" TYPE="number" LENGTH="15" NOTNULL="true" SEQUENCE="false" DECIMALS="5"/>
       </FIELDS>
       <KEYS>
-        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+          <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
       </KEYS>
     </TABLE>
+    <TABLE NAME="question_response_count" COMMENT="Count for each responses for each try at a question.">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="analysisid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="try" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="rcount" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+          <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+          <KEY NAME="analysisid" TYPE="foreign" FIELDS="analysisid" REFTABLE="question_response_analysis" REFFIELDS="id"/>
+        </KEYS>
+    </TABLE>
     <TABLE NAME="mnet_application" COMMENT="Information about applications on remote hosts">
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
index 0a73cc2..0cafecf 100644 (file)
@@ -3124,5 +3124,64 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2014022600.00);
     }
 
+    if ($oldversion < 2014031400.01) {
+        // Delete any cached stats to force recalculation later, then we can be sure that cached records will have the correct
+        // field.
+        $DB->delete_records('question_response_analysis');
+        $DB->delete_records('question_statistics');
+        $DB->delete_records('quiz_statistics');
+
+        // Define field response to be dropped from question_response_analysis.
+        $table = new xmldb_table('question_response_analysis');
+        $field = new xmldb_field('rcount');
+
+        // Conditionally launch drop field response.
+        if ($dbman->field_exists($table, $field)) {
+            $dbman->drop_field($table, $field);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2014031400.01);
+    }
+
+    if ($oldversion < 2014031400.02) {
+
+        // Define table question_response_count to be created.
+        $table = new xmldb_table('question_response_count');
+
+        // Adding fields to table question_response_count.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('analysisid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('try', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('rcount', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+
+        // Adding keys to table question_response_count.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+        $table->add_key('analysisid', XMLDB_KEY_FOREIGN, array('analysisid'), 'question_response_analysis', array('id'));
+
+        // Conditionally launch create table for question_response_count.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2014031400.02);
+    }
+
+    if ($oldversion < 2014031400.03) {
+
+        // Define field whichtries to be added to question_response_analysis.
+        $table = new xmldb_table('question_response_analysis');
+        $field = new xmldb_field('whichtries', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null, 'hashcode');
+
+        // Conditionally launch add field whichtries.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2014031400.03);
+    }
+
     return true;
 }
index fbd425a..9fa1c41 100644 (file)
@@ -37,6 +37,7 @@ $string['calculatingallstats'] = 'Calculating statistics for quiz, questions and
 $string['cic'] = 'Coefficient of internal consistency (for {$a})';
 $string['completestatsfilename'] = 'completestats';
 $string['count'] = 'Count';
+$string['counttryno'] = 'Count Try {$a}';
 $string['coursename'] = 'Course name';
 $string['detailedanalysis'] = 'More detailed analysis of the responses to this question';
 $string['detailedanalysisforvariant'] = 'More detailed analysis of the responses to variant {$a} of this question';
@@ -110,4 +111,5 @@ $string['statistics:view'] = 'View statistics report';
 $string['statsfor'] = 'Quiz statistics (for {$a})';
 $string['variant'] = 'Variant';
 $string['variantno'] = 'Variant {$a}';
+$string['whichtries'] = 'Analyze responses for';
 
index 6a41ddf..cd83de5 100644 (file)
@@ -18,7 +18,8 @@
  * Quiz statistics report class.
  *
  * @package   quiz_statistics
- * @copyright 2008 Jamie Pratt
+ * @copyright 2014 Open University
+ * @author    James Pratt <me@jamiep.org>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
@@ -38,9 +39,7 @@ require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');
  */
 class quiz_statistics_report extends quiz_default_report {
 
-    /**
-     * @var context_module
-     */
+    /** @var context_module context of this quiz.*/
     protected $context;
 
     /** @var quiz_statistics_table instance of table class used for main questions stats table. */
@@ -74,6 +73,7 @@ class quiz_statistics_report extends quiz_default_report {
         $slot = optional_param('slot', 0, PARAM_INT);
         $variantno = optional_param('variant', null, PARAM_INT);
         $whichattempts = optional_param('whichattempts', $quiz->grademethod, PARAM_INT);
+        $whichtries = optional_param('whichtries', question_attempt::LAST_TRY, PARAM_ALPHA);
 
         $pageoptions = array();
         $pageoptions['id'] = $cm->id;
@@ -81,18 +81,18 @@ class quiz_statistics_report extends quiz_default_report {
 
         $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions);
 
-        $mform = new quiz_statistics_settings_form($reporturl);
-
-        $mform->set_data(array('whichattempts' => $whichattempts));
+        $mform = new quiz_statistics_settings_form($reporturl, compact('quiz'));
 
-        if ($fromform = $mform->get_data()) {
-            $whichattempts = $fromform->whichattempts;
-        }
+        $mform->set_data(array('whichattempts' => $whichattempts, 'whichtries' => $whichtries));
 
         if ($whichattempts != $quiz->grademethod) {
             $reporturl->param('whichattempts', $whichattempts);
         }
 
+        if ($whichtries != question_attempt::LAST_TRY) {
+            $reporturl->param('whichtries', $whichtries);
+        }
+
         // Find out current groups mode.
         $currentgroup = $this->get_current_group($cm, $course, $this->context);
         $nostudentsingroup = false; // True if a group is selected and there is no one in it.
@@ -145,7 +145,7 @@ class quiz_statistics_report extends quiz_default_report {
             // Get the data to be displayed.
             $progress = $this->get_progress_trace_instance();
             list($quizstats, $questionstats) =
-                $this->get_all_stats_and_analysis($quiz, $whichattempts, $groupstudents, $questions, $progress);
+                $this->get_all_stats_and_analysis($quiz, $whichattempts, $whichtries, $groupstudents, $questions, $progress);
         } else {
             // Or create empty stats containers.
             $quizstats = new \quiz_statistics\calculated($whichattempts);
@@ -191,7 +191,7 @@ class quiz_statistics_report extends quiz_default_report {
                     $this->output_statistics_graph($quiz->id, $currentgroup, $whichattempts);
                 }
 
-                $this->output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl);
+                $this->output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl, $whichtries);
             }
 
             $this->table->export_class_instance()->finish_document();
@@ -207,7 +207,8 @@ class quiz_statistics_report extends quiz_default_report {
                                                                 $variantno,
                                                                 $questionstats->for_subq($qid, $variantno)->s,
                                                                 $reporturl,
-                                                                $qubaids);
+                                                                $qubaids,
+                                                                $whichtries);
             // Back to overview link.
             echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
                               get_string('backtoquizreport', 'quiz_statistics') . '</a>',
@@ -233,7 +234,8 @@ class quiz_statistics_report extends quiz_default_report {
                                                                     $variantno,
                                                                     $questionstats->for_slot($slot, $variantno)->s,
                                                                     $reporturl,
-                                                                    $qubaids);
+                                                                    $qubaids,
+                                                                    $whichtries);
             }
             if (!$this->table->is_downloading()) {
                 // Back to overview link.
@@ -256,7 +258,7 @@ class quiz_statistics_report extends quiz_default_report {
         } else {
             // On-screen display of overview report.
             echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3);
-            echo $this->output_caching_info($quizstats, $quiz->id, $groupstudents, $whichattempts, $reporturl);
+            echo $this->output_caching_info($quizstats->timemodified, $quiz->id, $groupstudents, $whichattempts, $reporturl);
             echo $this->everything_download_options();
             $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
             echo $this->output_quiz_info_table($quizinfo);
@@ -273,6 +275,7 @@ class quiz_statistics_report extends quiz_default_report {
     /**
      * Display the statistical and introductory information about a question.
      * Only called when not downloading.
+     *
      * @param object                                         $quiz         the quiz settings.
      * @param \core_question\statistics\questions\calculated $questionstat the question to report on.
      */
@@ -340,6 +343,8 @@ class quiz_statistics_report extends quiz_default_report {
     }
 
     /**
+     * Output question text in a box with urls appropriate for a preview of the question.
+     *
      * @param object $question question data.
      * @return string HTML of question text, ready for display.
      */
@@ -363,8 +368,10 @@ class quiz_statistics_report extends quiz_default_report {
      * @param int              $s
      * @param moodle_url       $reporturl the URL to redisplay this report.
      * @param qubaid_condition $qubaids
+     * @param string           $whichtries
      */
-    protected function output_individual_question_response_analysis($question, $variantno, $s, $reporturl, $qubaids) {
+    protected function output_individual_question_response_analysis($question, $variantno, $s, $reporturl, $qubaids,
+                                                                    $whichtries = question_attempt::LAST_TRY) {
         global $OUTPUT;
 
         if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
@@ -399,8 +406,8 @@ class quiz_statistics_report extends quiz_default_report {
             }
         }
 
-        $responesanalyser = new \core_question\statistics\responses\analyser($question);
-        $responseanalysis = $responesanalyser->load_cached($qubaids);
+        $responesanalyser = new \core_question\statistics\responses\analyser($question, $whichtries);
+        $responseanalysis = $responesanalyser->load_cached($qubaids, $whichtries);
 
         $qtable->question_setup($reporturl, $question, $s, $responseanalysis);
         if ($this->table->is_downloading()) {
@@ -427,6 +434,7 @@ class quiz_statistics_report extends quiz_default_report {
 
     /**
      * Output the table that lists all the questions in the quiz with their statistics.
+     *
      * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats the stats for all questions in
      *                                                                                               the quiz including subqs and
      *                                                                                               variants.
@@ -442,7 +450,8 @@ class quiz_statistics_report extends quiz_default_report {
     }
 
     /**
-     * Output the table of overall quiz statistics.
+     * Return HTML for table of overall quiz statistics.
+     *
      * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
      * @return string the HTML.
      */
@@ -463,6 +472,7 @@ class quiz_statistics_report extends quiz_default_report {
 
     /**
      * Download the table of overall quiz statistics.
+     *
      * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
      */
     protected function download_quiz_info_table($quizinfo) {
@@ -493,6 +503,7 @@ class quiz_statistics_report extends quiz_default_report {
 
     /**
      * Output the HTML needed to show the statistics graph.
+     *
      * @param $quizid
      * @param $currentgroup
      * @param $whichattempts
@@ -516,13 +527,15 @@ class quiz_statistics_report extends quiz_default_report {
      *                                   $quiz->grademethod ie.
      *                                   QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST
      *                                   we calculate stats based on which attempts would affect the grade for each student.
+     * @param string $whichtries         which tries to analyse for response analysis. Will be one of
+     *                                   question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
      * @param array  $groupstudents      students in this group.
      * @param array  $questions          full question data.
      * @param \core\progress\base|null   $progress
      * @return array with 2 elements:    - $quizstats The statistics for overall attempt scores.
      *                                   - $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition
      */
-    public function get_all_stats_and_analysis($quiz, $whichattempts, $groupstudents, $questions, $progress = null) {
+    public function get_all_stats_and_analysis($quiz, $whichattempts, $whichtries, $groupstudents, $questions, $progress = null) {
 
         if ($progress === null) {
             $progress = new \core\progress\null();
@@ -534,10 +547,9 @@ class quiz_statistics_report extends quiz_default_report {
 
         $quizcalc = new \quiz_statistics\calculator($progress);
 
+        $progress->start_progress('', 3);
         if ($quizcalc->get_last_calculated_time($qubaids) === false) {
 
-            $progress->start_progress('', 3);
-
             // Recalculate now.
             $questionstats = $qcalc->calculate($qubaids);
             $progress->progress(1);
@@ -545,17 +557,24 @@ class quiz_statistics_report extends quiz_default_report {
             $quizstats = $quizcalc->calculate($quiz->id, $whichattempts, $groupstudents, count($questions),
                                               $qcalc->get_sum_of_mark_variance());
             $progress->progress(2);
-            if ($quizstats->s()) {
-                $subquestions = $questionstats->get_sub_questions();
-                $this->analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids, $progress);
-            }
-            $progress->progress(3);
-            $progress->end_progress();
         } else {
             $quizstats = $quizcalc->get_cached($qubaids);
+            $progress->progress(1);
             $questionstats = $qcalc->get_cached($qubaids);
+            $progress->progress(2);
         }
 
+        if ($quizstats->s()) {
+            $subquestions = $questionstats->get_sub_questions();
+            $this->analyse_responses_for_all_questions_and_subquestions($questions,
+                                                                        $subquestions,
+                                                                        $qubaids,
+                                                                        $whichtries,
+                                                                        $progress);
+        }
+        $progress->progress(3);
+        $progress->end_progress();
+
         return array($quizstats, $questionstats);
     }
 
@@ -576,7 +595,17 @@ class quiz_statistics_report extends quiz_default_report {
         return $this->progress;
     }
 
-    protected function analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids, $progress = null) {
+    /**
+     * Analyse responses for all questions and sub questions in this quiz.
+     *
+     * @param object[] $questions as returned by self::load_and_initialise_questions_for_calculations
+     * @param object[] $subquestions full question objects.
+     * @param qubaid_condition $qubaids the question usages whose responses to analyse.
+     * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
+     * @param null|\core\progress\base $progress Used to indicate progress of task.
+     */
+    protected function analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids,
+                                                                            $whichtries, $progress = null) {
         if ($progress === null) {
             $progress = new \core\progress\null();
         }
@@ -584,15 +613,25 @@ class quiz_statistics_report extends quiz_default_report {
         // Starting response analysis tasks.
         $progress->start_progress('', count($questions) + count($subquestions));
 
-        $done = $this->analyse_responses_for_questions($questions, $qubaids, $progress);
+        $done = $this->analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress);
 
-        $this->analyse_responses_for_questions($subquestions, $qubaids, $progress, $done);
+        $this->analyse_responses_for_questions($subquestions, $qubaids, $whichtries, $progress, $done);
 
         // Finished all response analysis tasks.
         $progress->end_progress();
     }
 
-    protected function analyse_responses_for_questions($questions, $qubaids, $progress = null, $done = array()) {
+    /**
+     * Analyse responses for an array of questions or sub questions.
+     *
+     * @param object[] $questions  as returned by self::load_and_initialise_questions_for_calculations.
+     * @param qubaid_condition $qubaids the question usages whose responses to analyse.
+     * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
+     * @param null|\core\progress\base $progress Used to indicate progress of task.
+     * @param int[] $done array keys are ids of questions that have been analysed before calling method.
+     * @return array array keys are ids of questions that were analysed after this method call.
+     */
+    protected function analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress = null, $done = array()) {
         $countquestions = count($questions);
         if (!$countquestions) {
             return array();
@@ -604,8 +643,10 @@ class quiz_statistics_report extends quiz_default_report {
         foreach ($questions as $question) {
             $progress->increment_progress();
             if (question_bank::get_qtype($question->qtype, false)->can_analyse_responses()  && !isset($done[$question->id])) {
-                $responesstats = new \core_question\statistics\responses\analyser($question);
-                $responesstats->calculate($qubaids);
+                $responesstats = new \core_question\statistics\responses\analyser($question, $whichtries);
+                if ($responesstats->get_last_analysed_time($qubaids, $whichtries) === false) {
+                    $responesstats->calculate($qubaids, $whichtries);
+                }
             }
             $done[$question->id] = 1;
         }
@@ -614,7 +655,10 @@ class quiz_statistics_report extends quiz_default_report {
     }
 
     /**
-     * @return string HTML snipped for the Download full report as UI.
+     * Return a little form for the user to request to download the full report, including quiz stats and response analysis for
+     * all questions and sub-questions.
+     *
+     * @return string HTML.
      */
     protected function everything_download_options() {
         $downloadoptions = $this->table->get_download_menu();
@@ -635,9 +679,9 @@ class quiz_statistics_report extends quiz_default_report {
     }
 
     /**
-     * Generate the snipped of HTML that says when the stats were last caculated,
-     * with a recalcuate now button.
-     * @param object $quizstats      the overall quiz statistics.
+     * Return HTML for a message that says when the stats were last calculated and a 'recalculate now' button.
+     *
+     * @param int    $lastcachetime  the time the stats were last cached.
      * @param int    $quizid         the quiz id.
      * @param array  $groupstudents  ids of students in the group or empty array if groups not used.
      * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in
@@ -645,13 +689,12 @@ class quiz_statistics_report extends quiz_default_report {
      *                                   QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST
      *                                   we calculate stats based on which attempts would affect the grade for each student.
      * @param moodle_url $reporturl url for this report
-     * @return string a HTML snipped saying when the stats were last computed,
-     *      or blank if that is not appropriate.
+     * @return string HTML.
      */
-    protected function output_caching_info($quizstats, $quizid, $groupstudents, $whichattempts, $reporturl) {
+    protected function output_caching_info($lastcachetime, $quizid, $groupstudents, $whichattempts, $reporturl) {
         global $DB, $OUTPUT;
 
-        if (empty($quizstats->timemodified)) {
+        if (empty($lastcachetime)) {
             return '';
         }
 
@@ -661,7 +704,7 @@ class quiz_statistics_report extends quiz_default_report {
                 SELECT COUNT(1)
                 FROM $fromqa
                 WHERE $whereqa
-                AND quiza.timefinish > {$quizstats->timemodified}", $qaparams);
+                AND quiza.timefinish > {$lastcachetime}", $qaparams);
 
         if (!$count) {
             $count = 0;
@@ -669,7 +712,7 @@ class quiz_statistics_report extends quiz_default_report {
 
         // Generate the output.
         $a = new stdClass();
-        $a->lastcalculated = format_time(time() - $quizstats->timemodified);
+        $a->lastcalculated = format_time(time() - $lastcachetime);
         $a->count = $count;
 
         $recalcualteurl = new moodle_url($reporturl,
@@ -686,8 +729,9 @@ class quiz_statistics_report extends quiz_default_report {
     }
 
     /**
-     * Clear the cached data for a particular report configuration. This will
-     * trigger a re-computation the next time the report is displayed.
+     * Clear the cached data for a particular report configuration. This will trigger a re-computation the next time the report
+     * is displayed.
+     *
      * @param $qubaids qubaid_condition
      */
     protected function clear_cached_data($qubaids) {
@@ -698,6 +742,8 @@ class quiz_statistics_report extends quiz_default_report {
     }
 
     /**
+     * Load the questions in this quiz and add some properties to the objects needed in the reports.
+     *
      * @param object $quiz the quiz.
      * @return array of questions for this quiz.
      */
@@ -726,8 +772,13 @@ class quiz_statistics_report extends quiz_default_report {
      * @param $questions
      * @param $questionstats
      * @param $reporturl
+     * @param $whichtries string
      */
-    protected function output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl) {
+    protected function output_all_question_response_analysis($qubaids,
+                                                             $questions,
+                                                             $questionstats,
+                                                             $reporturl,
+                                                             $whichtries = question_attempt::LAST_TRY) {
         foreach ($questions as $slot => $question) {
             if (question_bank::get_qtype(
                 $question->qtype, false)->can_analyse_responses()
@@ -738,14 +789,16 @@ class quiz_statistics_report extends quiz_default_report {
                                                                             $variantno,
                                                                             $questionstats->for_slot($slot, $variantno)->s,
                                                                             $reporturl,
-                                                                            $qubaids);
+                                                                            $qubaids,
+                                                                            $whichtries);
                     }
                 } else {
                     $this->output_individual_question_response_analysis($question,
                                                                         null,
                                                                         $questionstats->for_slot($slot)->s,
                                                                         $reporturl,
-                                                                        $qubaids);
+                                                                        $qubaids,
+                                                                        $whichtries);
                 }
             } else if ($subqids = $questionstats->for_slot($slot)->get_sub_question_ids()) {
                 foreach ($subqids as $subqid) {
@@ -756,7 +809,8 @@ class quiz_statistics_report extends quiz_default_report {
                                 $variantno,
                                 $questionstats->for_subq($subqid, $variantno)->s,
                                 $reporturl,
-                                $qubaids);
+                                $qubaids,
+                                $whichtries);
                         }
                     } else {
                         $this->output_individual_question_response_analysis(
@@ -764,7 +818,8 @@ class quiz_statistics_report extends quiz_default_report {
                             null,
                             $questionstats->for_subq($subqid)->s,
                             $reporturl,
-                            $qubaids);
+                            $qubaids,
+                            $whichtries);
 
                     }
                 }
index 114ae32..0ca216f 100644 (file)
@@ -18,7 +18,8 @@
  * Quiz statistics settings form definition.
  *
  * @package   quiz_statistics
- * @copyright 2008 Jamie Pratt
+ * @copyright 2014 Open University
+ * @author    James Pratt <me@jamiep.org>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
@@ -29,7 +30,9 @@ require_once($CFG->libdir . '/formslib.php');
 /**
  * This is the settings form for the quiz statistics report.
  *
- * @copyright 2008 Jamie Pratt
+ * @package   quiz_statistics
+ * @copyright 2014 Open University
+ * @author    James Pratt <me@jamiep.org>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class quiz_statistics_settings_form extends moodleform {
@@ -45,6 +48,15 @@ class quiz_statistics_settings_form extends moodleform {
 
         $mform->addElement('select', 'whichattempts', get_string('calculatefrom', 'quiz_statistics'), $options);
 
+        if (quiz_allows_multiple_tries($this->_customdata['quiz'])) {
+            $mform->addElement('select', 'whichtries', get_string('whichtries', 'quiz_statistics'), array(
+                                           question_attempt::FIRST_TRY    => get_string('firsttry', 'question'),
+                                           question_attempt::LAST_TRY     => get_string('lasttry', 'question'),
+                                           question_attempt::ALL_TRIES    => get_string('alltries', 'question'))
+            );
+            $mform->setDefault('whichtries', question_attempt::LAST_TRY);
+        }
         $mform->addElement('submit', 'submitbutton', get_string('preferencessave', 'quiz_overview'));
     }
+
 }
index 5591e50..2545ffe 100644 (file)
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Quiz statistics report, table for showing statistics about a particular question.
+ * Quiz statistics report, table for showing response analysis for a particular question (or sub question).
  *
  * @package   quiz_statistics
- * @copyright 2008 Jamie Pratt
+ * @copyright 2014 Open University
+ * @author    James Pratt <me@jamiep.org>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
@@ -31,10 +32,11 @@ require_once($CFG->libdir . '/tablelib.php');
  *
  * Lists the responses that students made to this question, with frequency counts.
  *
- * The responses may be grouped, either by subpart of the question, or by the
+ * The responses may be grouped, either by sub-part of the question, or by the
  * answer they match.
  *
- * @copyright 2008 Jamie Pratt
+ * @copyright 2014 Open University
+ * @author    James Pratt <me@jamiep.org>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class quiz_statistics_question_table extends flexible_table {
@@ -46,6 +48,7 @@ class quiz_statistics_question_table extends flexible_table {
 
     /**
      * Constructor.
+     *
      * @param int $qid the id of the particular question whose statistics are being
      * displayed.
      */
@@ -54,6 +57,8 @@ class quiz_statistics_question_table extends flexible_table {
     }
 
     /**
+     * Set up columns and column names and other table settings.
+     *
      * @param moodle_url $reporturl
      * @param object     $questiondata
      * @param integer    $s             number of attempts on this question.
@@ -93,8 +98,16 @@ class quiz_statistics_question_table extends flexible_table {
         $columns[] = 'fraction';
         $headers[] = get_string('optiongrade', 'quiz_statistics');
 
-        $columns[] = 'count';
-        $headers[] = get_string('count', 'quiz_statistics');
+        if (!$responseanalysis->has_multiple_tries_data()) {
+            $columns[] = 'totalcount';
+            $headers[] = get_string('count', 'quiz_statistics');
+        } else {
+            $countcolumns = range(1, $responseanalysis->get_maximum_tries());
+            foreach ($countcolumns as $countcolumn) {
+                $columns[] = 'trycount'.$countcolumn;
+                $headers[] = get_string('counttryno', 'quiz_statistics', $countcolumn);
+            }
+        }
 
         $columns[] = 'frequency';
         $headers[] = get_string('frequency', 'quiz_statistics');
@@ -113,6 +126,12 @@ class quiz_statistics_question_table extends flexible_table {
         parent::setup();
     }
 
+    /**
+     * Take a float where 1 represents 100% and return a string representing the percentage.
+     *
+     * @param float $fraction The fraction.
+     * @return string The fraction as a percentage.
+     */
     protected function format_percentage($fraction) {
         return format_float($fraction * 100, 2) . '%';
     }
@@ -139,7 +158,27 @@ class quiz_statistics_question_table extends flexible_table {
         if (!$this->s) {
             return '';
         }
+        return $this->format_percentage($response->totalcount / $this->s);
+    }
 
-        return $this->format_percentage($response->count / $this->s);
+    /**
+     * If there is not a col_{column name} method then we call this method. If it returns null
+     * that means just output the property as in the table raw data. If this returns none null
+     * then this is the output for this cell of the table.
+     *
+     * @param string $colname  The name of this column.
+     * @param object $response The raw data for this row.
+     * @return string|null The value for this cell of the table or null means use raw data.
+     */
+    public function other_cols($colname, $response) {
+        if (preg_match('/^trycount(\d+)$/', $colname, $matches)) {
+            if (isset($response->trycount[$matches[1]])) {
+                return $response->trycount[$matches[1]];
+            } else {
+                return 0;
+            }
+        } else {
+            return null;
+        }
     }
 }
index 1ba861e..74bae16 100644 (file)
@@ -1,8 +1,11 @@
-slot,facility,sd,effectiveweight,covariance,markvariance,othermarkvariance,discriminationindex,covariancemax,discriminativeefficiency
-1,0.704,0.4513682901,21.2922742344,-0.022555556,0.2037333333,0.5002777794,-7.0650767526,0.2385555565,-9.4550536967
-2,0.48,0.5099019514,18.8979800309,-0.1172777785,0.26,0.6334555578,-28.8982125772,0.318833334,-36.7834118938
-3,0.973333332,0.13333334,4.443012573,-0.0098888894,0.0177777796,0.6609,-9.1230674268,0.045666669,-21.6545012165
-4,0.68,0.4760952286,18.9347251357,-0.0833888893,0.2266666667,0.5990111128,-22.6306444113,0.2652222232,-31.4411395613
-5,0.52,0.3055050463,11.1450138688,-0.0436944444,0.0933333333,0.6529555563,-17.6997047674,0.2063055556,-21.1794802584
-6,0.64,0.4898979486,9.8081339177,-0.2015555547,0.24,0.8220111101,-45.3785178421,0.3539999995,-56.9365974439
-7,0.62,0.331662479,15.4788602394,-0.0142499998,0.11,0.5774000005,-5.6543166602,0.2190833335,-6.5043742058
+slot,subqname,variant,s,facility,sd,effectiveweight,covariance,markvariance,othermarkvariance,discriminationindex,covariancemax,discriminativeefficiency,maxmark
+1,,,25,0.704,0.4513682901,21.2922742344,-0.022555556,0.2037333333,0.5002777794,-7.0650767526,0.2385555565,-9.4550536967,1
+1,numerical,,12,0.583333333,0.514928651,**NULL**,,,,35.803933,,39.39393939,1
+2,,,25,0.48,0.5099019514,18.8979800309,-0.1172777785,0.26,0.6334555578,-28.8982125772,0.318833334,-36.7834118938,1
+2,,1,6,0.50,0.5477225575,**NULL**,,,,-10.5999788,,-14.28571429,1
+2,,8,5,0.40,0.547722558,**NULL**,,,,-57.77466679,,-71.05263241,1
+3,,,25,0.973333332,0.13333334,4.443012573,-0.0098888894,0.0177777796,0.6609,-9.1230674268,0.045666669,-21.6545012165,1
+4,,,25,0.68,0.4760952286,18.9347251357,-0.0833888893,0.2266666667,0.5990111128,-22.6306444113,0.2652222232,-31.4411395613,1
+5,,,25,0.52,0.3055050463,11.1450138688,-0.0436944444,0.0933333333,0.6529555563,-17.6997047674,0.2063055556,-21.1794802584,1
+6,,,25,0.64,0.4898979486,9.8081339177,-0.2015555547,0.24,0.8220111101,-45.3785178421,0.3539999995,-56.9365974439,1
+7,,,25,0.62,0.331662479,15.4788602394,-0.0142499998,0.11,0.5774000005,-5.6543166602,0.2190833335,-6.5043742058,1
diff --git a/mod/quiz/report/statistics/tests/fixtures/questions01.csv b/mod/quiz/report/statistics/tests/fixtures/questions01.csv
new file mode 100644 (file)
index 0000000..76c0bd2
--- /dev/null
@@ -0,0 +1,7 @@
+slot,type,which,cat,mark,overrides.hint.0.text,overrides.hint.0.format,overrides.hint.1.text,overrides.hint.1.format,overrides.hint.2.text,overrides.hint.2.format,overrides.hint.3.text,overrides.hint.3.format,overrides.shuffleanswers
+1,random,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0
+,shortanswer,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0
+,numerical,,rand,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0
+2,calculatedsimple,sumwithvariants,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0
+3,match,,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0
+4,truefalse,,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,0
diff --git a/mod/quiz/report/statistics/tests/fixtures/questions02.csv b/mod/quiz/report/statistics/tests/fixtures/questions02.csv
new file mode 100644 (file)
index 0000000..9977455
--- /dev/null
@@ -0,0 +1,2 @@
+slot,type,which,cat,mark,overrides.hint.0.text,overrides.hint.0.format,overrides.hint.1.text,overrides.hint.1.format,overrides.hint.2.text,overrides.hint.2.format,overrides.hint.3.text,overrides.hint.3.format,overrides.hint.4.text,overrides.hint.4.format,overrides.shuffleanswers
+1,match,,maincat,1,"Hint 1",0,"Hint 2",0,"Hint 3",0,"Hint 4",0,"Hint 5",0,0
index 872ca30..50ba9ca 100644 (file)
@@ -1,4 +1,4 @@
-slot,randq,variant,subpart,modelresponse,actualresponse,count
+slot,randq,variant,subpart,modelresponse,actualresponse,totalcount
 1,numerical,1,1,"3.142 (3.142..3.142)",3.142,1
 1,numerical,1,1,"3.14 (3.14..3.14)",3.14,7
 1,numerical,1,1,"3.1 (3.1..3.1)",3.1,4
@@ -10,7 +10,7 @@ slot,randq,variant,subpart,modelresponse,actualresponse,count
 2,,1,1,[NO MATCH],-0.2,1
 2,,1,1,[NO MATCH],-1,1
 2,,4,1,{a} + {b} (±0.01 Relative),19.4,2
-2,,4,1,[NO RESPONSE],,1
+2,,4,1,"[NO RESPONSE]",,1
 2,,4,1,[NO MATCH],-0.4,1
 3,,1,1,frog: amphibian,amphibian,25
 3,,1,2,cat: mammal,mammal,24
diff --git a/mod/quiz/report/statistics/tests/fixtures/responsecounts01.csv b/mod/quiz/report/statistics/tests/fixtures/responsecounts01.csv
new file mode 100644 (file)
index 0000000..de81954
--- /dev/null
@@ -0,0 +1,73 @@
+slot,randq,variant,subpart,modelresponse,actualresponse,count1,count2,count3,count4,count5,totalcount
+1,shortanswer,1,1,frog,,0,0,0,0,0,0
+1,shortanswer,1,1,toad,toad,1,0,1,0,0,2
+1,shortanswer,1,1,*,butterfly,1,0,0,0,0,1
+1,shortanswer,1,1,*,dog,1,1,0,0,0,2
+1,shortanswer,1,1,*,chicken,0,0,1,0,0,1
+1,shortanswer,1,1,*,Tod,1,0,0,0,0,1
+1,shortanswer,1,1,*,Tony,0,1,0,0,0,1
+1,shortanswer,1,1,*,Sharon,0,0,1,0,0,1
+1,shortanswer,1,1,*,snake,0,1,0,0,0,1
+1,shortanswer,1,1,*,snakes,0,0,1,0,0,1
+1,shortanswer,1,1,*,Snakes,0,0,0,1,0,1
+1,shortanswer,1,1,*,SnakeS,0,0,0,0,1,1
+1,shortanswer,1,1,*,goat,1,0,0,0,0,1
+1,shortanswer,1,1,*,"Mexican burrowing caecilian",0,0,1,0,0,1
+1,shortanswer,1,1,*,newt,0,0,0,1,0,1
+1,shortanswer,1,1,*,human,0,0,0,0,1,1
+1,shortanswer,1,1,*,eggs,1,0,0,0,0,1
+1,shortanswer,1,1,"[No response]",,0,0,0,0,0,0
+1,numerical,1,1,3.14 (3.14..3.14),3.14,2,0,0,0,0,2
+1,numerical,1,1,3.142 (3.142..3.142),,0,0,0,0,0,0
+1,numerical,1,1,3.1 (3.1..3.1),3.1,1,0,0,0,0,1
+1,numerical,1,1,3 (3..3),,0,0,0,0,0,0
+1,numerical,1,1,*,2,1,0,0,0,0,1
+1,numerical,1,1,*,20,0,1,0,0,0,1
+1,numerical,1,1,*,34,0,0,1,0,0,1
+1,numerical,1,1,[No response],,0,0,0,0,0,0
+2,,1,1,{a} + {b} (±0.01 Relative),9.9,0,0,0,0,1,1
+2,,1,1,[Did not match any answer],23,1,0,0,0,0,1
+2,,1,1,[Did not match any answer],22,0,1,0,0,0,1
+2,,1,1,[Did not match any answer],21,0,0,1,0,0,1
+2,,1,1,[Did not match any answer],9,0,0,0,1,0,1
+2,,1,1,[No response],,0,0,0,0,0,0
+2,,2,1,{a} + {b} (±0.01 Relative),8.5,1,0,0,0,1,2
+2,,2,1,"[Did not match any answer]",19.4,0,1,0,0,0,1
+2,,2,1,[Did not match any answer],4.5,1,0,0,0,0,1
+2,,2,1,"[Did not match any answer]",8,0,0,0,1,0,1
+2,,2,1,[No response],,0,0,0,0,0,0
+2,,3,1,{a} + {b} (±0.01 Relative),3.3,0,1,0,0,0,1
+2,,3,1,[Did not match any answer],19.4,1,0,0,0,0,1
+2,,3,1,[No response],,0,0,0,0,0,0
+2,,4,1,{a} + {b} (±0.01 Relative),19.4,2,0,0,0,0,2
+2,,4,1,{a} + {b} (±0.01 Relative),19.3,1,0,0,0,0,1
+2,,4,1,[Did not match any answer],,0,0,0,0,0,0
+2,,4,1,[No response],,0,0,0,0,0,0
+2,,6,1,"{a} + {b} (±0.01 Relative)",9.4,1,0,0,0,0,1
+2,,6,1,"[Did not match any answer]",,0,0,0,0,0,0
+2,,6,1,"[No response]",,0,0,0,0,0,0
+2,,9,1,"{a} + {b} (±0.01 Relative)",,0,0,0,0,0,0
+2,,9,1,"[Did not match any answer]",7,1,0,0,0,0,1
+2,,9,1,"[No response]",,0,0,0,0,0,0
+2,,10,1,"{a} + {b} (±0.01 Relative)",,0,0,0,0,0,0
+2,,10,1,"[Did not match any answer]",555,1,0,0,0,0,1
+2,,10,1,"[Did not match any answer]",44,0,1,0,0,0,1
+2,,10,1,"[Did not match any answer]",22,0,0,1,0,0,1
+2,,10,1,"[Did not match any answer]",11,0,0,0,1,0,1
+2,,10,1,"[Did not match any answer]",12,0,0,0,0,1,1
+2,,10,1,"[No response]",,0,0,0,0,0,0
+3,,1,1,"frog: amphibian",amphibian,8,0,1,0,0,9
+3,,1,1,frog: mammal,mammal,0,0,0,1,1,2
+3,,1,1,frog: insect,insect,4,3,0,0,0,7
+3,,1,1,[No response],,0,0,0,0,0,0
+3,,1,2,"cat: amphibian",amphibian,8,0,1,1,0,10
+3,,1,2,cat: mammal,mammal,0,1,1,2,0,4
+3,,1,2,"cat: insect",insect,4,1,1,1,0,7
+3,,1,2,[No response],,0,0,0,0,0,0
+3,,1,3,"newt: amphibian",amphibian,6,2,1,0,0,9
+3,,1,3,"newt: mammal",mammal,3,0,0,1,0,5
+3,,1,3,newt: insect,insect,3,2,0,0,0,5
+3,,1,3,[No response],,0,0,0,0,0,0
+4,,1,1,False,,3,0,0,0,0,3
+4,,1,1,True,,9,0,0,0,0,9
+4,,1,1,[No response],,0,0,0,0,0,0
diff --git a/mod/quiz/report/statistics/tests/fixtures/responsecounts02.csv b/mod/quiz/report/statistics/tests/fixtures/responsecounts02.csv
new file mode 100644 (file)
index 0000000..c766a6e
--- /dev/null
@@ -0,0 +1,6 @@
+slot,subpart,modelresponse,actualresponse,totalcount,count1,count2,count3,count4,count5
+1,1,frog: insect,insect,2,2,0,0,0,0
+1,1,"frog: mammal",mammal,1,0,0,1,0,0
+1,2,"cat: insect",insect,2,1,1,0,0,0
+1,2,cat: amphibian,amphibian,1,1,0,0,0,0
+1,3,newt: insect,insect,2,2,0,0,0,0
diff --git a/mod/quiz/report/statistics/tests/fixtures/steps01.csv b/mod/quiz/report/statistics/tests/fixtures/steps01.csv
new file mode 100644 (file)
index 0000000..952a537
--- /dev/null
@@ -0,0 +1,83 @@
+quizattempt,firstname,lastname,finished,randqs.1,responses.1.-submit,responses.1.-tryagain,responses.1.answer,responses.2.-submit,responses.2.-tryagain,responses.2.answer,variants.2,responses.3.-submit,responses.3.-tryagain,responses.3.0,responses.3.1,responses.3.2,responses.4.-submit,responses.4.answer
+1,John,Jones,0,shortanswer,1,0,butterfly,0,0,19.4,4,0,0,,amphibian,,0,1
+1,John,Jones,0,shortanswer,0,1,butterfly,0,0,19.4,4,1,0,insect,insect,insect,0,1
+1,John,Jones,0,shortanswer,1,0,dog,0,0,19.4,4,0,1,insect,insect,insect,0,1
+1,John,Jones,0,shortanswer,0,1,dog,0,0,19.4,4,1,0,insect,insect,amphibian,0,1
+1,John,Jones,0,shortanswer,1,0,chicken,0,0,19.4,4,0,1,insect,insect,amphibian,0,1
+1,John,Jones,0,shortanswer,0,0,chicken,0,0,19.4,4,1,0,insect,mammal,amphibian,0,1
+1,John,Jones,0,shortanswer,0,0,chicken,0,0,19.4,4,0,1,insect,mammal,amphibian,0,1
+1,John,Jones,0,shortanswer,0,0,chicken,0,0,19.4,4,1,0,insect,amphibian,amphibian,0,1
+1,John,Jones,0,shortanswer,0,0,chicken,0,0,19.4,4,0,1,insect,amphibian,amphibian,0,1
+1,John,Jones,1,shortanswer,0,0,chicken,0,0,19.4,4,1,0,mammal,amphibian,amphibian,0,1
+2,Han,Solo,0,shortanswer,1,0,Tod,1,0,19.4,2,1,0,amphibian,amphibian,amphibian,1,0
+2,Han,Solo,0,shortanswer,0,1,Tod,0,1,19.4,2,0,1,amphibian,amphibian,amphibian,0,0
+2,Han,Solo,0,shortanswer,1,0,Tony,0,0,19.4,2,1,0,insect,mammal,insect,0,0
+2,Han,Solo,0,shortanswer,0,1,Tony,0,0,19.4,2,0,1,insect,mammal,insect,0,0
+2,Han,Solo,0,shortanswer,1,0,Sharon,0,0,19.4,2,1,0,amphibian,insect,mammal,0,0
+2,Han,Solo,0,shortanswer,0,0,Sharon,0,0,19.4,2,0,1,amphibian,insect,mammal,0,0
+2,Han,Solo,0,shortanswer,0,0,Sharon,0,0,19.4,2,1,0,amphibian,insect,mammal,0,0
+2,Han,Solo,1,shortanswer,0,0,Sharon,0,0,19.4,2,0,0,amphibian,insect,mammal,0,0
+3,Yoda,Wise He Is,0,shortanswer,1,0,snake,1,0,7,9,1,0,amphibian,insect,amphibian,0,1
+3,Yoda,Wise He Is,0,shortanswer,0,1,snake,0,0,7,9,0,1,amphibian,insect,amphibian,0,1
+3,Yoda,Wise He Is,0,shortanswer,1,0,snake,0,0,7,9,1,0,amphibian,insect,amphibian,0,1
+3,Yoda,Wise He Is,0,shortanswer,0,1,snake,0,0,7,9,0,1,amphibian,insect,amphibian,0,1
+3,Yoda,Wise He Is,0,shortanswer,1,0,snakes,0,0,7,9,1,0,amphibian,insect,amphibian,0,1
+3,Yoda,Wise He Is,0,shortanswer,0,1,snakes,0,0,7,9,0,1,amphibian,insect,amphibian,0,1
+3,Yoda,Wise He Is,0,shortanswer,1,0,Snakes,0,0,7,9,1,0,amphibian,mammal,amphibian,0,1
+3,Yoda,Wise He Is,0,shortanswer,0,1,Snakes,0,0,7,9,0,0,amphibian,mammal,amphibian,0,1
+3,Yoda,Wise He Is,1,shortanswer,1,0,SnakeS,0,0,7,9,0,0,amphibian,mammal,amphibian,0,1
+4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,1,0,amphibian,amphibian,amphibian,0,1
+4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,0,1,amphibian,amphibian,amphibian,0,1
+4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,1,0,insect,insect,insect,0,1
+4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,0,1,insect,insect,insect,0,1
+4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,1,0,insect,amphibian,amphibian,0,1
+4,Herbert,Garrison,0,shortanswer,0,0,dog,0,0,9.4,6,0,1,insect,amphibian,amphibian,0,1
+4,Herbert,Garrison,1,shortanswer,0,0,dog,0,0,9.4,6,1,0,mammal,insect,mammal,0,1
+5,Agent,Smith,0,numerical,1,0,3.1,1,0,x+y,2,0,0,amphibian,amphibian,mammal,0,1
+5,Agent,Smith,0,numerical,0,0,3.1,1,0,4.5,2,0,0,amphibian,amphibian,mammal,0,1
+5,Agent,Smith,1,numerical,0,0,3.1,0,0,4.5,2,0,0,amphibian,amphibian,mammal,0,1
+6,Agent,Smith,0,numerical,0,0,3.142,0,0,19.4,3,0,0,amphibian,insect,mammal,1,0
+6,Agent,Smith,0,numerical,1,0,3.14,1,0,19.4,3,1,0,amphibian,insect,mammal,0,0
+6,Agent,Smith,0,numerical,0,0,3.14,0,1,19.4,3,0,1,amphibian,insect,mammal,0,0
+6,Agent,Smith,1,numerical,0,0,3.14,1,0,3.3,3,1,0,insect,insect,amphibian,0,0
+7,Agent,Smith,0,numerical,1,0,3.14,1,0,19.3,4,0,0,insect,amphibian,insect,0,1
+7,Agent,Smith,1,numerical,0,0,3.14,0,0,19.3,4,0,0,insect,amphibian,insect,0,1
+9,Bebe,Stevens,0,shortanswer,1,0,goat,1,0,8.5,2,0,0,amphibian,amphibian,insect,0,1
+9,Bebe,Stevens,0,shortanswer,0,1,goat,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1
+9,Bebe,Stevens,0,shortanswer,1,0,Mexican burrowing caecilian,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1
+9,Bebe,Stevens,0,shortanswer,0,1,Mexican burrowing caecilian,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1
+9,Bebe,Stevens,0,shortanswer,1,0,Mexican burrowing caecilian,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1
+9,Bebe,Stevens,0,shortanswer,0,1,Mexican burrowing caecilian,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1
+9,Bebe,Stevens,0,shortanswer,1,0,newt,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1
+9,Bebe,Stevens,0,shortanswer,0,1,newt,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1
+9,Bebe,Stevens,1,shortanswer,1,0,human,0,0,8.5,2,0,0,amphibian,amphibian,insect,0,1
+10,Luke,Skywalker,0,numerical,1,0,2,1,0,555,10,1,0,amphibian,amphibian,amphibian,1,0
+10,Luke,Skywalker,0,numerical,0,1,2,0,1,555,10,0,1,amphibian,amphibian,amphibian,0,0
+10,Luke,Skywalker,0,numerical,1,0,20,1,0,44,10,1,0,amphibian,amphibian,amphibian,0,0
+10,Luke,Skywalker,0,numerical,0,1,20,0,1,44,10,0,1,amphibian,amphibian,amphibian,0,0
+10,Luke,Skywalker,0,numerical,1,0,34,1,0,22,10,1,0,amphibian,amphibian,amphibian,0,0
+10,Luke,Skywalker,0,numerical,0,0,34,0,1,22,10,0,1,amphibian,amphibian,amphibian,0,0
+10,Luke,Skywalker,0,numerical,0,0,34,1,0,11,10,1,0,amphibian,mammal,amphibian,0,0
+10,Luke,Skywalker,0,numerical,0,0,34,0,1,11,10,0,0,amphibian,mammal,amphibian,0,0
+10,Luke,Skywalker,1,numerical,0,0,34,1,0,12,10,0,0,amphibian,mammal,amphibian,0,0
+11,Luke,Skywalker,0,shortanswer,1,0,toad,1,0,23,1,1,0,insect,amphibian,amphibian,0,1
+11,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,23,1,0,0,insect,amphibian,amphibian,0,1
+11,Luke,Skywalker,0,shortanswer,0,0,toad,1,0,22,1,0,0,insect,amphibian,amphibian,0,1
+11,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,22,1,0,0,insect,amphibian,amphibian,0,1
+11,Luke,Skywalker,0,shortanswer,0,0,toad,1,0,21,1,0,0,insect,amphibian,amphibian,0,1
+11,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,21,1,0,0,insect,amphibian,amphibian,0,1
+11,Luke,Skywalker,0,shortanswer,0,0,toad,1,0,9,1,0,0,insect,amphibian,amphibian,0,1
+11,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,9,1,0,0,insect,amphibian,amphibian,0,1
+11,Luke,Skywalker,1,shortanswer,0,0,toad,1,0,9.9,1,0,0,insect,amphibian,amphibian,0,1
+12,Luke,Skywalker,0,shortanswer,1,0,toad,1,0,8,2,1,0,insect,insect,amphibian,0,1
+12,Luke,Skywalker,0,shortanswer,0,1,toad,0,1,8,2,0,0,insect,insect,amphibian,0,1
+12,Luke,Skywalker,0,shortanswer,1,0,toad,1,0,8,2,0,0,insect,insect,amphibian,0,1
+12,Luke,Skywalker,0,shortanswer,0,1,toad,0,1,8,2,0,0,insect,insect,amphibian,0,1
+12,Luke,Skywalker,0,shortanswer,1,0,toad,1,0,8,2,0,0,insect,insect,amphibian,0,1
+12,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,8,2,0,0,insect,insect,amphibian,0,1
+12,Luke,Skywalker,0,shortanswer,0,0,toad,1,0,8,2,0,0,insect,insect,amphibian,0,1
+12,Luke,Skywalker,0,shortanswer,0,0,toad,0,1,8,2,0,0,insect,insect,amphibian,0,1
+12,Luke,Skywalker,1,shortanswer,0,0,toad,1,0,8.5,2,0,0,insect,insect,amphibian,0,1
+13,Leia,The Princess,0,shortanswer,0,0,eggs,0,0,19.4,4,1,0,amphibian,amphibian,,1,1
+13,Leia,The Princess,0,shortanswer,0,0,eggs,0,0,19.4,4,1,0,amphibian,amphibian,mammal,0,1
+13,Leia,The Princess,1,shortanswer,0,0,eggs,0,0,19.4,4,0,0,amphibian,amphibian,mammal,0,1
diff --git a/mod/quiz/report/statistics/tests/fixtures/steps02.csv b/mod/quiz/report/statistics/tests/fixtures/steps02.csv
new file mode 100644 (file)
index 0000000..89535d7
--- /dev/null
@@ -0,0 +1,19 @@
+quizattempt,firstname,lastname,responses.1.0,responses.1.1,responses.1.2,responses.1.-submit,responses.1.-tryagain,finished
+1,John,Jones,insect,insect,insect,1,0,0
+1,John,Jones,insect,insect,insect,0,1,0
+1,John,Jones,insect,insect,insect,1,0,0
+1,John,Jones,insect,insect,insect,0,1,0
+1,John,Jones,insect,insect,insect,1,0,0
+1,John,Jones,insect,insect,insect,0,1,0
+1,John,Jones,insect,insect,insect,1,0,0
+1,John,Jones,insect,insect,insect,0,1,0
+1,John,Jones,insect,insect,insect,1,0,1
+2,Mark,Jones,insect,amphibian,insect,1,0,0
+2,Mark,Jones,insect,amphibian,insect,0,1,0
+2,Mark,Jones,insect,insect,insect,1,0,0
+2,Mark,Jones,insect,insect,insect,0,1,0
+2,Mark,Jones,mammal,insect,insect,1,0,0
+2,Mark,Jones,mammal,insect,insect,0,1,0
+2,Mark,Jones,mammal,insect,insect,1,0,0
+2,Mark,Jones,mammal,insect,insect,0,1,0
+2,Mark,Jones,mammal,insect,insect,1,0,1
index 57b7dee..80fdd31 100644 (file)
 /**
  * Quiz attempt walk through using data from csv file.
  *
+ * The quiz stats below and the question stats found in qstats00.csv were calculated independently in a spreadsheet which is
+ * available in open document or excel format here :
+ * https://github.com/jamiepratt/moodle-quiz-tools/tree/master/statsspreadsheet
+ *
+ * Similarly the question variant's stats in qstats00.csv are calculated in stats_for_variant_1.xls and stats_for_variant_8.xls
+ * The calculations in the spreadsheets are the same as for the other question stats but applied just to the attempts where the
+ * variants appeared.
+ *
  * @package    quiz_statistics
  * @category   phpunit
  * @copyright  2013 The Open University
@@ -74,14 +82,20 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth
 
         $attemptids = $this->walkthrough_attempts($csvdata['steps']);
 
-        $this->check_attempts_results($csvdata['results'], $attemptids);
+        if (isset($csvdata['results'])) {
+            $this->check_attempts_results($csvdata['results'], $attemptids);
+        }
 
         $this->report = new quiz_statistics_report();
-        $whichattempts = QUIZ_GRADEAVERAGE;
+        $whichattempts = QUIZ_GRADEAVERAGE; // All attempts.
+        $whichtries = question_attempt::ALL_TRIES;
         $groupstudents = array();
         $questions = $this->report->load_and_initialise_questions_for_calculations($this->quiz);
-        list($quizstats, $questionstats) =
-                        $this->report->get_all_stats_and_analysis($this->quiz, $whichattempts, $groupstudents, $questions);
+        list($quizstats, $questionstats) = $this->report->get_all_stats_and_analysis($this->quiz,
+                                                                                     $whichattempts,
+                                                                                     $whichtries,
+                                                                                     $groupstudents,
+                                                                                     $questions);
 
         $qubaids = quiz_statistics_qubaids_condition($this->quiz->id, $groupstudents, $whichattempts);
 
@@ -94,187 +108,72 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth
         $qcalc = new \core_question\statistics\questions\calculator($questions);
         $this->assertTimeCurrent($qcalc->get_last_calculated_time($qubaids));
 
-        $expectedvariantcounts = array(2 => array(1 => 6,
-                                                    4 => 4,
-                                                    5 => 3,
-                                                    6 => 4,
-                                                    7 => 2,
-                                                    8 => 5,
-                                                    10 => 1));
-
-        foreach ($questions as $slot => $question) {
-            if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
-                continue;
-            }
-            $responesstats = new \core_question\statistics\responses\analyser($question);
-            $this->assertTimeCurrent($responesstats->get_last_analysed_time($qubaids));
-            $analysis = $responesstats->load_cached($qubaids);
-            $variantsnos = $analysis->get_variant_nos();
-            if (isset($expectedvariantcounts[$slot])) {
-                // Compare contents, ignore ordering of array, using canonicalize parameter of assertEquals.
-                $this->assertEquals(array_keys($expectedvariantcounts[$slot]), $variantsnos, '', 0, 10, true);
-            } else {
-                $this->assertEquals(array(1), $variantsnos);
-            }
-            $totalspervariantno = array();
-            foreach ($variantsnos as $variantno) {
-
-                $subpartids = $analysis->get_subpart_ids($variantno);
-                foreach ($subpartids as $subpartid) {
-                    if (!isset($totalspervariantno[$subpartid])) {
-                        $totalspervariantno[$subpartid] = array();
-                    }
-                    $totalspervariantno[$subpartid][$variantno] = 0;
-
-                    $subpartanalysis = $analysis->get_analysis_for_subpart($variantno, $subpartid);
-                    $classids = $subpartanalysis->get_response_class_ids();
-                    foreach ($classids as $classid) {
-                        $classanalysis = $subpartanalysis->get_response_class($classid);
-                        $actualresponsecounts = $classanalysis->data_for_question_response_table('', '');
-                        foreach ($actualresponsecounts as $actualresponsecount) {
-                            $totalspervariantno[$subpartid][$variantno] += $actualresponsecount->count;
-                        }
-                    }
-                }
-            }
-            // Count all counted responses for each part of question and confirm that counted responses, for most question types
-            // are the number of attempts at the question for each question part.
-            if ($slot != 5) {
-                // Slot 5 holds a multi-choice multiple question.
-                // Multi-choice multiple is slightly strange. Actual answer counts given for each sub part do not add up to the
-                // total attempt count.
-                // This is because each option is counted as a sub part and each option can be off or on in each attempt. Off is
-                // not counted in response analysis for this question type.
-                foreach ($totalspervariantno as $totalpervariantno) {
-                    if (isset($expectedvariantcounts[$slot])) {
-                        // If we know how many attempts there are at each variant we can check
-                        // that we have counted the correct amount of responses for each variant.
-                        $this->assertEquals($expectedvariantcounts[$slot],
-                                            $totalpervariantno,
-                                            "Totals responses do not add up in response analysis for slot {$slot}.",
-                                            0,
-                                            10,
-                                            true);
-                    } else {
-                        $this->assertEquals(25,
-                                            array_sum($totalpervariantno),
-                                            "Totals responses do not add up in response analysis for slot {$slot}.");
-                    }
-                }
-            }
+        if (isset($csvdata['responsecounts'])) {
+            $this->check_response_counts($csvdata['responsecounts'], $qubaids, $questions, $whichtries);
         }
-        for ($rowno = 0; $rowno < $csvdata['responsecounts']->getRowCount(); $rowno++) {
-            $responsecount = $csvdata['responsecounts']->getRow($rowno);
-            if ($responsecount['randq'] == '') {
-                $question = $questions[$responsecount['slot']];
-            } else {
-                $qid = $this->randqids[$responsecount['slot']][$responsecount['randq']];
-                $question = question_finder::get_instance()->load_question_data($qid);
-            }
-            $this->assert_response_count_equals($question, $qubaids, $responsecount);
+        if (isset($csvdata['qstats'])) {
+            $this->check_question_stats($csvdata['qstats'], $questionstats);
         }
-
-        // These quiz stats and the question stats found in qstats00.csv were calculated independently in spreadsheet which is
-        // available in open document or excel format here :
-        // https://github.com/jamiepratt/moodle-quiz-tools/tree/master/statsspreadsheet
-        $quizstatsexpected = array(
-            'median' => 4.5,
-            'firstattemptsavg' => 4.617333332,
-            'allattemptsavg' => 4.617333332,
-            'firstattemptscount' => 25,
-            'allattemptscount' => 25,
-            'standarddeviation' => 0.8117265554,
-            'skewness' => -0.092502502,
-            'kurtosis' => -0.7073968557,
-            'cic' => -87.2230935542,
-            'errorratio' => 136.8294900795,
-            'standarderror' => 1.1106813066
-        );
-
-        foreach ($quizstatsexpected as $statname => $statvalue) {
-            $this->assertEquals($statvalue, $quizstats->$statname, $quizstats->$statname, abs($statvalue) * 1.5e-5);
+        if ($quizsettings['testnumber'] === '00') {
+            $this->check_variants_count_for_quiz_00($questions, $questionstats, $whichtries, $qubaids);
+            $this->check_quiz_stats_for_quiz_00($quizstats);
         }
+    }
 
-        for ($rowno = 0; $rowno < $csvdata['qstats']->getRowCount(); $rowno++) {
-            $slotqstats = $csvdata['qstats']->getRow($rowno);
+    /**
+     * Check actual question stats are the same as that found in csv file.
+     *
+     * @param $qstats         PHPUnit_Extensions_Database_DataSet_ITable data from csv file.
+     * @param $questionstats  \core_question\statistics\questions\all_calculated_for_qubaid_condition Calculated stats.
+     */
+    protected function check_question_stats($qstats, $questionstats) {
+        for ($rowno = 0; $rowno < $qstats->getRowCount(); $rowno++) {
+            $slotqstats = $qstats->getRow($rowno);
             foreach ($slotqstats as $statname => $slotqstat) {
-                if ($statname !== 'slot') {
-                    $this->assert_stat_equals($questionstats, $slotqstats['slot'], null, null, $statname, (float)$slotqstat);
+                if (!in_array($statname, array('slot', 'subqname'))  && $slotqstat !== '') {
+                    $this->assert_stat_equals($slotqstat,
+                                              $questionstats,
+                                              $slotqstats['slot'],
+                                              $slotqstats['subqname'],
+                                              $slotqstats['variant'],
+                                              $statname);
                 }
             }
-        }
-
-        $itemstats = array('s' => 12,
-                          'effectiveweight' => null,
-                          'discriminationindex' => 35.803933,
-                          'discriminativeefficiency' => 39.39393939,
-                          'sd' => 0.514928651,
-                          'facility' => 0.583333333,
-                          'maxmark' => 1,
-                          'positions' => '1',
-                          'slot' => null,
-                          'subquestion' => true);
-        foreach ($itemstats as $statname => $expected) {
-            $this->assert_stat_equals($questionstats, 1, null, 'numerical', $statname, $expected);
-        }
-
-        // These variant's stats are calculated in stats_for_variant_1.xls and stats_for_variant_8.xls
-        // The calculations in the spreadsheets are the same but applied just to the attempts where the variants appeared.
-
-        $statsforslot2variants = array(1 => array('s' => 6,
-                                                    'effectiveweight' => null,
-                                                    'discriminationindex' => -10.5999788,
-                                                    'discriminativeefficiency' => -14.28571429,
-                                                    'sd' => 0.5477225575,
-                                                    'facility' => 0.50,
-                                                    'maxmark' => 1,
-                                                    'variant' => 1,
-                                                    'slot' => 2,
-                                                    'subquestion' => false),
-                                      8 => array('s' => 5,
-                                                    'effectiveweight' => null,
-                                                    'discriminationindex' => -57.77466679,
-                                                    'discriminativeefficiency' => -71.05263241,
-                                                    'sd' => 0.547722558,
-                                                    'facility' => 0.40,
-                                                    'maxmark' => 1,
-                                                    'variant' => 8,
-                                                    'slot' => 2,
-                                                    'subquestion' => false));
-        foreach ($statsforslot2variants as $variant => $stats) {
-            foreach ($stats as $statname => $expected) {
-                $this->assert_stat_equals($questionstats, 2, $variant, null, $statname, $expected);
-            }
-        }
-        foreach ($expectedvariantcounts as $slot => $expectedvariantcount) {
-            foreach ($expectedvariantcount as $variantno => $s) {
-                $this->assertEquals($s, $questionstats->for_slot($slot, $variantno)->s);
-            }
+            // Check that sub-question boolean field is correctly set.
+            $this->assert_stat_equals(!empty($slotqstats['subqname']),
+                                      $questionstats,
+                                      $slotqstats['slot'],
+                                      $slotqstats['subqname'],
+                                      $slotqstats['variant'],
+                                      'subquestion');
         }
     }
 
     /**
      * Check that the stat is as expected within a reasonable tolerance.
      *
+     * @param float|string|bool $expected expected value of stat.
      * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats
-     * @param int                                              $slot
-     * @param int|null                                         $variant if null then not a variant stat.
-     * @param string|null                                      $subqname if null then not an item stat.
-     * @param string                                           $statname
-     * @param float                                            $expected
+     * @param int $slot
+     * @param string $subqname if empty string then not an item stat.
+     * @param int|string $variant if empty string then not a variantstat.
+     * @param string $statname
      */
-    protected function assert_stat_equals($questionstats, $slot, $variant, $subqname, $statname, $expected) {
+    protected function assert_stat_equals($expected, $questionstats, $slot, $subqname, $variant, $statname) {
 
-        if ($variant === null && $subqname === null) {
+        if ($variant === '' && $subqname === '') {
             $actual = $questionstats->for_slot($slot)->{$statname};
-        } else if ($subqname !== null) {
+        } else if ($subqname !== '') {
             $actual = $questionstats->for_subq($this->randqids[$slot][$subqname])->{$statname};
         } else {
             $actual = $questionstats->for_slot($slot, $variant)->{$statname};
         }
-        if (is_bool($expected) || is_string($expected)) {
-            $this->assertEquals($expected, $actual, "$statname for slot $slot");
-        } else {
+        $message = "$statname for slot $slot";
+        if ($expected === '**NULL**') {
+            $this->assertEquals(null, $actual, $message);
+        } else if (is_bool($expected)) {
+            $this->assertEquals($expected, $actual, $message);
+        } else if (is_numeric($expected)) {
             switch ($statname) {
                 case 'covariance' :
                 case 'discriminationindex' :
@@ -286,37 +185,52 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth
                     $precision = 1e-6;
             }
             $delta = abs($expected) * $precision;
-            $this->assertEquals(floatval($expected), $actual, "$statname for slot $slot", $delta);
+            $this->assertEquals((float)$expected, $actual, $message, $delta);
+        } else {
+            $this->assertEquals($expected, $actual, $message);
         }
     }
 
-    protected function assert_response_count_equals($question, $qubaids, $responsecount) {
+    protected function assert_response_count_equals($question, $qubaids, $expected, $whichtries) {
         $responesstats = new \core_question\statistics\responses\analyser($question);
-        $analysis = $responesstats->load_cached($qubaids);
-        if (!isset($responsecount['subpart'])) {
+        $analysis = $responesstats->load_cached($qubaids, $whichtries);
+        if (!isset($expected['subpart'])) {
             $subpart = 1;
         } else {
-            $subpart = $responsecount['subpart'];
+            $subpart = $expected['subpart'];
         }
         list($subpartid, $responseclassid) = $this->get_response_subpart_and_class_id($question,
                                                                                       $subpart,
-                                                                                      $responsecount['modelresponse']);
+                                                                                      $expected['modelresponse']);
 
-        $subpartanalysis = $analysis->get_analysis_for_subpart($responsecount['variant'], $subpartid);
+        $subpartanalysis = $analysis->get_analysis_for_subpart($expected['variant'], $subpartid);
         $responseclassanalysis = $subpartanalysis->get_response_class($responseclassid);
         $actualresponsecounts = $responseclassanalysis->data_for_question_response_table('', '');
-        if ($responsecount['modelresponse'] !== '[NO RESPONSE]') {
-            foreach ($actualresponsecounts as $actualresponsecount) {
-                if ($actualresponsecount->response == $responsecount['actualresponse']) {
-                    $this->assertEquals($responsecount['count'], $actualresponsecount->count);
-                    return;
+
+        foreach ($actualresponsecounts as $actualresponsecount) {
+            if ($actualresponsecount->response == $expected['actualresponse'] ||
+                            count($actualresponsecounts) == 1) {
+                $i = 1;
+                $partofanalysis = " slot {$expected['slot']}, rand q '{$expected['randq']}', variant {$expected['variant']}, ".
+                                    "for expected model response {$expected['modelresponse']}, ".
+                                    "actual response {$expected['actualresponse']}";
+                while (isset($expected['count'.$i])) {
+                    if ($expected['count'.$i] != 0) {
+                        $this->assertTrue(isset($actualresponsecount->trycount[$i]),
+                            "There is no count at all for try $i on ".$partofanalysis);
+                        $this->assertEquals($expected['count'.$i], $actualresponsecount->trycount[$i],
+                                            "Count for try $i on ".$partofanalysis);
+                    }
+                    $i++;
                 }
+                if (isset($expected['totalcount'])) {
+                    $this->assertEquals($expected['totalcount'], $actualresponsecount->totalcount,
+                                        "Total count on ".$partofanalysis);
+                }
+                return;
             }
-            throw new coding_exception("Actual response '{$responsecount['actualresponse']}' not found.");
-        } else {
-            $actualresponsecount = array_pop($actualresponsecounts);
-            $this->assertEquals($responsecount['count'], $actualresponsecount->count);
         }
+        throw new coding_exception("Expected response '{$expected['actualresponse']}' not found.");
     }
 
     protected function get_response_subpart_and_class_id($question, $subpart, $modelresponse) {
@@ -344,4 +258,136 @@ class quiz_report_statistics_from_steps_testcase extends mod_quiz_attempt_walkth
         return array($subpartid, $responseclassid);
     }
 
+    /**
+     * @param $responsecounts
+     * @param $qubaids
+     * @param $questions
+     * @param $whichtries
+     */
+    protected function check_response_counts($responsecounts, $qubaids, $questions, $whichtries) {
+        for ($rowno = 0; $rowno < $responsecounts->getRowCount(); $rowno++) {
+            $expected = $responsecounts->getRow($rowno);
+            $defaultsforexpected = array('randq' => '', 'variant' => '1', 'subpart' => '1');
+            foreach ($defaultsforexpected as $key => $expecteddefault) {
+                if (!isset($expected[$key])) {
+                    $expected[$key] = $expecteddefault;
+                }
+            }
+            if ($expected['randq'] == '') {
+                $question = $questions[$expected['slot']];
+            } else {
+                $qid = $this->randqids[$expected['slot']][$expected['randq']];
+                $question = question_finder::get_instance()->load_question_data($qid);
+            }
+            $this->assert_response_count_equals($question, $qubaids, $expected, $whichtries);
+        }
+    }
+
+    /**
+     * @param $questions
+     * @param $questionstats
+     * @param $whichtries
+     * @param $qubaids
+     */
+    protected function check_variants_count_for_quiz_00($questions, $questionstats, $whichtries, $qubaids) {
+        $expectedvariantcounts = array(2 => array(1  => 6,
+                                                  4  => 4,
+                                                  5  => 3,
+                                                  6  => 4,
+                                                  7  => 2,
+                                                  8  => 5,
+                                                  10 => 1));
+
+        foreach ($questions as $slot => $question) {
+            if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
+                continue;
+            }
+            $responesstats = new \core_question\statistics\responses\analyser($question);
+            $this->assertTimeCurrent($responesstats->get_last_analysed_time($qubaids, $whichtries));
+            $analysis = $responesstats->load_cached($qubaids, $whichtries);
+            $variantsnos = $analysis->get_variant_nos();
+            if (isset($expectedvariantcounts[$slot])) {
+                // Compare contents, ignore ordering of array, using canonicalize parameter of assertEquals.
+                $this->assertEquals(array_keys($expectedvariantcounts[$slot]), $variantsnos, '', 0, 10, true);
+            } else {
+                $this->assertEquals(array(1), $variantsnos);
+            }
+            $totalspervariantno = array();
+            foreach ($variantsnos as $variantno) {
+
+                $subpartids = $analysis->get_subpart_ids($variantno);
+                foreach ($subpartids as $subpartid) {
+                    if (!isset($totalspervariantno[$subpartid])) {
+                        $totalspervariantno[$subpartid] = array();
+                    }
+                    $totalspervariantno[$subpartid][$variantno] = 0;
+
+                    $subpartanalysis = $analysis->get_analysis_for_subpart($variantno, $subpartid);
+                    $classids = $subpartanalysis->get_response_class_ids();
+                    foreach ($classids as $classid) {
+                        $classanalysis = $subpartanalysis->get_response_class($classid);
+                        $actualresponsecounts = $classanalysis->data_for_question_response_table('', '');
+                        foreach ($actualresponsecounts as $actualresponsecount) {
+                            $totalspervariantno[$subpartid][$variantno] += $actualresponsecount->totalcount;
+                        }
+                    }
+                }
+            }
+            // Count all counted responses for each part of question and confirm that counted responses, for most question types
+            // are the number of attempts at the question for each question part.
+            if ($slot != 5) {
+                // Slot 5 holds a multi-choice multiple question.
+                // Multi-choice multiple is slightly strange. Actual answer counts given for each sub part do not add up to the
+                // total attempt count.
+                // This is because each option is counted as a sub part and each option can be off or on in each attempt. Off is
+                // not counted in response analysis for this question type.
+                foreach ($totalspervariantno as $totalpervariantno) {
+                    if (isset($expectedvariantcounts[$slot])) {
+                        // If we know how many attempts there are at each variant we can check
+                        // that we have counted the correct amount of responses for each variant.
+                        $this->assertEquals($expectedvariantcounts[$slot],
+                                            $totalpervariantno,
+                                            "Totals responses do not add up in response analysis for slot {$slot}.",
+                                            0,
+                                            10,
+                                            true);
+                    } else {
+                        $this->assertEquals(25,
+                                            array_sum($totalpervariantno),
+                                            "Totals responses do not add up in response analysis for slot {$slot}.");
+                    }
+                }
+            }
+        }
+
+        foreach ($expectedvariantcounts as $slot => $expectedvariantcount) {
+            foreach ($expectedvariantcount as $variantno => $s) {
+                $this->assertEquals($s, $questionstats->for_slot($slot, $variantno)->s);
+            }
+        }
+    }
+
+    /**
+     * @param $quizstats
+     */
+    protected function check_quiz_stats_for_quiz_00($quizstats) {
+        $quizstatsexpected = array(
+            'median'             => 4.5,
+            'firstattemptsavg'   => 4.617333332,
+            'allattemptsavg'     => 4.617333332,
+            'firstattemptscount' => 25,
+            'allattemptscount'   => 25,
+            'standarddeviation'  => 0.8117265554,
+            'skewness'           => -0.092502502,
+            'kurtosis'           => -0.7073968557,
+            'cic'                => -87.2230935542,
+            'errorratio'         => 136.8294900795,
+            'standarderror'      => 1.1106813066
+        );
+
+        foreach ($quizstatsexpected as $statname => $statvalue) {
+            $this->assertEquals($statvalue, $quizstats->$statname, $quizstats->$statname, abs($statvalue) * 1.5e-5);
+        }
+    }
+
 }
index 5598b3d..364335e 100644 (file)
@@ -183,7 +183,9 @@ class mod_quiz_attempt_walkthrough_from_csv_testcase extends advanced_testcase {
             $quizsettings = $quizzes->getRow($rowno);
             $dataset = array();
             foreach ($this->files as $file) {
-                $dataset[$file] = $this->load_csv_data_file($file, $quizsettings['testnumber']);
+                if (file_exists($this->get_full_path_of_csv_file($file, $quizsettings['testnumber']))) {
+                    $dataset[$file] = $this->load_csv_data_file($file, $quizsettings['testnumber']);
+                }
             }
             $datasets[] = array($quizsettings, $dataset);
         }
index bd576c1..d690bdd 100644 (file)
@@ -281,10 +281,70 @@ abstract class question_behaviour {
     }
 
     /**
-     * @return question_possible_response[] where keys are subpartid or an empty array if no classification is possible.
-     */
-    public function classify_response() {
-        return $this->question->classify_response($this->qa->get_last_qt_data());
+     * Classify responses for this question into a number of sub parts and response classes as defined by
+     * {@link \question_type::get_possible_responses} for this question type.
+     *
+     * @param string $whichtries         which tries to analyse for response analysis. Will be one of
+     *                                   question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
+     *                                   Defaults to question_attempt::LAST_TRY.
+     * @return (question_classified_response|array)[] If $whichtries is question_attempt::FIRST_TRY or LAST_TRY index is subpartid
+     *                                   and values are question_classified_response instances.
+     *                                   If $whichtries is question_attempt::ALL_TRIES then first key is submitted response no
+     *                                   and the second key is subpartid.
+     */
+    public function classify_response($whichtries = question_attempt::LAST_TRY) {
+        if ($whichtries == question_attempt::LAST_TRY) {
+            return $this->question->classify_response($this->qa->get_last_qt_data());
+        } else {
+            $stepswithsubmit = $this->qa->get_steps_with_submitted_response_iterator();
+            if ($whichtries == question_attempt::FIRST_TRY) {
+                return $this->question->classify_response($stepswithsubmit[1]->get_qt_data());
+            } else {
+                $classifiedresponses = array();
+                foreach ($stepswithsubmit as $submittedresponseno => $step) {
+                    $classifiedresponses[$submittedresponseno] = $this->question->classify_response($step->get_qt_data());
+                }
+                return $this->remove_repeated_submitted_responses($classifiedresponses);
+            }
+        }
+    }
+
+    /**
+     * Filter classified responses for multiple tries to remove identical responses that are not significant.
+     *
+     * In base class we compare last response to preceding responses and remove all identical responses until a different one is
+     * found then for that different response compare to preceding and remove all identical until ...
+     *
+     * @param array[] $classifiedresponses first index is submitted response no and second is sub-part id. Value is of type
+     *                                     question_classified_response. Return value from self::classify_response for ALL_TRIES.
+     * @return array[] return non repeated responses.
+     */
+    protected function remove_repeated_submitted_responses($classifiedresponses) {
+        $submittedresponsenos = array_keys($classifiedresponses);
+        $submittedresponsenos = array_reverse($submittedresponsenos);
+        $lastsubmittedresponseno = array_shift($submittedresponsenos);
+        $nooflastnewresponse = $lastsubmittedresponseno;
+        while (count($submittedresponsenos)) {
+            $precedingresponseno = array_shift($submittedresponsenos);
+            $responsesrepeated = true;
+            if (count($classifiedresponses[$precedingresponseno]) !== count($classifiedresponses[$nooflastnewresponse])) {
+                $responsesrepeated = false;
+            } else {
+                foreach (array_keys($classifiedresponses[$precedingresponseno]) as $subpartid) {
+                    if ($classifiedresponses[$precedingresponseno][$subpartid] !=
+                                        $classifiedresponses[$nooflastnewresponse][$subpartid]) {
+                        $responsesrepeated = false;
+                        break;
+                    }
+                }
+            }
+            if ($responsesrepeated) {
+                unset($classifiedresponses[$precedingresponseno]);
+            } else {
+                $nooflastnewresponse = $precedingresponseno;
+            }
+        }
+        return $classifiedresponses;
     }
 
     /**
index 20c650b..81088c6 100644 (file)
@@ -90,4 +90,31 @@ class qbehaviour_interactivecountback extends qbehaviour_interactive {
 
         return $this->question->compute_final_grade($responses, $totaltries);
     }
+
+    /**
+     * Filter classified responses for multiple tries to remove identical responses that are not significant.
+     *
+     * For this behaviour the significant repeated response part are the first of any repeated responses, for any part of the
+     * question. These are the responses that are graded.
+     *
+     * @param array[] $classifiedresponses first index is submitted response no and second is sub-part id. Value is of type
+     *                                     question_classified_response. Return value from self::classify_response for ALL_TRIES.
+     * @return array[] return non repeated responses.
+     */
+    protected function remove_repeated_submitted_responses($classifiedresponses) {
+        $subpartids = array_keys($classifiedresponses[1]);
+        foreach ($subpartids as $subpartid) {
+            $lastdifferentresponsepart = 1;
+            $tryno = 2;
+            while (isset($classifiedresponses[$tryno])) {
+                if ($classifiedresponses[$tryno][$subpartid] != $classifiedresponses[$lastdifferentresponsepart][$subpartid]) {
+                    $lastdifferentresponsepart = $tryno;
+                } else {
+                    unset($classifiedresponses[$tryno][$subpartid]);
+                }
+                $tryno++;
+            }
+        }
+        return $classifiedresponses;
+    }
 }
index 96fdc41..594ce6f 100644 (file)
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * This file contains the code to analyse all the responses to a particular
- * question.
+ * This file contains the code to analyse all the responses to a particular question.
  *
  * @package    core_question
- * @copyright  2013 Open University
+ * @copyright  2014 Open University
  * @author     Jamie Pratt <me@jamiep.org>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -28,19 +27,22 @@ namespace core_question\statistics\responses;
 defined('MOODLE_INTERNAL') || die();
 
 /**
- * This class can store and compute the analysis of the responses to a particular
- * question.
+ * This class can compute, store and cache the analysis of the responses to a particular question.
  *
- * @copyright 2013 Open University
- * @author    Jamie Pratt <me@jamiep.org>
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @package    core_question
+ * @copyright  2014 The Open University
+ * @author     James Pratt me@jamiep.org
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class analyser {
+    /** @var int Time after which responses are automatically reanalysed. */
+    const TIME_TO_CACHE = 900; // 15 minutes.
+
     /** @var object full question data from db. */
     protected $questiondata;
 
     /**
-     * @var analysis_for_question
+     * @var analysis_for_question|analysis_for_question_all_tries
      */
     public $analysis;
 
@@ -63,15 +65,23 @@ class analyser {
      * for a particular question.
      *
      * @param object $questiondata the full question data from the database defining this question.
+     * @param string $whichtries   which tries to analyse.
      */
-    public function __construct($questiondata) {
+    public function __construct($questiondata, $whichtries = \question_attempt::LAST_TRY) {
         $this->questiondata = $questiondata;
         $qtypeobj = \question_bank::get_qtype($this->questiondata->qtype);
-        $this->analysis = new analysis_for_question($qtypeobj->get_possible_responses($this->questiondata));
+        if ($whichtries != \question_attempt::ALL_TRIES) {
+            $this->analysis = new analysis_for_question($qtypeobj->get_possible_responses($this->questiondata));
+        } else {
+            $this->analysis = new analysis_for_question_all_tries($qtypeobj->get_possible_responses($this->questiondata));
+        }
+
         $this->breakdownbyvariant = $qtypeobj->break_down_stats_and_response_analysis_by_variant($this->questiondata);
     }
 
     /**
+     * Does the computed analysis have sub parts?
+     *
      * @return bool whether this analysis has more than one subpart.
      */
     public function has_subparts() {
@@ -79,6 +89,8 @@ class analyser {
     }
 
     /**
+     * Does the computed analysis's sub parts have classes?
+     *
      * @return bool whether this analysis has (a subpart with) more than one response class.
      */
     public function has_response_classes() {
@@ -91,40 +103,21 @@ class analyser {
     }
 
     /**
-     * @return bool whether this analysis has a response class more than one
-     *      different acutal response, or if the actual response is different from
-     *      the model response.
-     */
-    public function has_actual_responses() {
-        foreach ($this->responseclasses as $subpartid => $partclasses) {
-            foreach ($partclasses as $responseclassid => $modelresponse) {
-                $numresponses = count($this->responses[$subpartid][$responseclassid]);
-                if ($numresponses > 1) {
-                    return true;
-                }
-                $actualresponse = key($this->responses[$subpartid][$responseclassid]);
-                if ($numresponses == 1 && $actualresponse != $modelresponse->responseclass) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-
-    /**
-     * Analyse all the response data for for all the specified attempts at
-     * this question.
+     * Analyse all the response data for for all the specified attempts at this question.
+     *
      * @param \qubaid_condition $qubaids which attempts to consider.
+     * @param string $whichtries         which tries to analyse. Will be one of
+     *                                   \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
      * @return analysis_for_question
      */
-    public function calculate($qubaids) {
+    public function calculate($qubaids, $whichtries = \question_attempt::LAST_TRY) {
         // Load data.
         $dm = new \question_engine_data_mapper();
         $questionattempts = $dm->load_attempts_at_question($this->questiondata->id, $qubaids);
 
         // Analyse it.
         foreach ($questionattempts as $qa) {
-            $responseparts = $qa->classify_response();
+            $responseparts = $qa->classify_response($whichtries);
             if ($this->breakdownbyvariant) {
                 $this->analysis->count_response_parts($qa->get_variant(), $responseparts);
             } else {
@@ -132,33 +125,44 @@ class analyser {
             }
 
         }
-        $this->analysis->cache($qubaids, $this->questiondata->id);
+        $this->analysis->cache($qubaids, $whichtries, $this->questiondata->id);
         return $this->analysis;
     }
 
-    /** @var integer Time after which responses are automatically reanalysed. */
-    const TIME_TO_CACHE = 900; // 15 minutes.
-
-
     /**
      * Retrieve the computed response analysis from the question_response_analysis table.
      *
-     * @param \qubaid_condition $qubaids which attempts to get cached response analysis for.
+     * @param \qubaid_condition $qubaids    load the analysis of which question usages?
+     * @param string            $whichtries load the analysis of which tries?
      * @return analysis_for_question|boolean analysis or false if no cached analysis found.
      */
-    public function load_cached($qubaids) {
+    public function load_cached($qubaids, $whichtries) {
         global $DB;
 
         $timemodified = time() - self::TIME_TO_CACHE;
-        $rows = $DB->get_records_select('question_response_analysis', 'hashcode = ? AND questionid = ? AND timemodified > ?',
-                                        array($qubaids->get_hash_code(), $this->questiondata->id, $timemodified));
-        if (!$rows) {
+        // Variable name 'analyses' is the plural of 'analysis'.
+        $responseanalyses = $DB->get_records_select('question_response_analysis',
+                                            'hashcode = ? AND whichtries = ? AND questionid = ? AND timemodified > ?',
+                                            array($qubaids->get_hash_code(), $whichtries, $this->questiondata->id, $timemodified));
+        if (!$responseanalyses) {
             return false;
         }
 
-        foreach ($rows as $row) {
-            $class = $this->analysis->get_analysis_for_subpart($row->variant, $row->subqid)->get_response_class($row->aid);
-            $class->add_response_and_count($row->response, $row->credit, $row->rcount);
+        $analysisids = array();
+        foreach ($responseanalyses as $responseanalysis) {
+            $analysisforsubpart = $this->analysis->get_analysis_for_subpart($responseanalysis->variant, $responseanalysis->subqid);
+            $class = $analysisforsubpart->get_response_class($responseanalysis->aid);
+            $class->add_response($responseanalysis->response, $responseanalysis->credit);
+            $analysisids[] = $responseanalysis->id;
+        }
+        list($sql, $params) = $DB->get_in_or_equal($analysisids);
+        $counts = $DB->get_records_select('question_response_count', "analysisid {$sql}", $params);
+        foreach ($counts as $count) {
+            $responseanalysis = $responseanalyses[$count->analysisid];
+            $analysisforsubpart = $this->analysis->get_analysis_for_subpart($responseanalysis->variant, $responseanalysis->subqid);
+            $class = $analysisforsubpart->get_response_class($responseanalysis->aid);
+            $class->set_response_count($responseanalysis->response, $count->try, $count->rcount);
+
         }
         return $this->analysis;
     }
@@ -167,15 +171,17 @@ class analyser {
     /**
      * Find time of non-expired analysis in the database.
      *
-     * @param $qubaids \qubaid_condition
+     * @param \qubaid_condition $qubaids    check for the analysis of which question usages?
+     * @param string            $whichtries check for the analysis of which tries?
      * @return integer|boolean Time of cached record that matches this qubaid_condition or false if none found.
      */
-    public function get_last_analysed_time($qubaids) {
+    public function get_last_analysed_time($qubaids, $whichtries) {
         global $DB;
 
         $timemodified = time() - self::TIME_TO_CACHE;
         return $DB->get_field_select('question_response_analysis', 'timemodified',
-                                     'hashcode = ? AND questionid = ? AND timemodified > ?',
-                                     array($qubaids->get_hash_code(), $this->questiondata->id, $timemodified), IGNORE_MULTIPLE);
+                                     'hashcode = ? AND whichtries = ? AND questionid = ? AND timemodified > ?',
+                                     array($qubaids->get_hash_code(), $whichtries, $this->questiondata->id, $timemodified),
+                                     IGNORE_MULTIPLE);
     }
 }
index a491777..56ca962 100644 (file)
 
 namespace core_question\statistics\responses;
 
-
+/**
+ * The leafs of the analysis data structure.
+ *
+ * - There is a separate data structure for each question or sub question's analysis
+ * {@link \core_question\statistics\responses\analysis_for_question}
+ * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}.
+ * - There are separate analysis for each variant in this top level instance.
+ * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question.
+ * {@link \core_question\statistics\responses\analysis_for_subpart}.
+ * - Then within the sub part analysis there are response class analysis
+ * {@link \core_question\statistics\responses\analysis_for_class}.
+ * - Then within each class analysis there are analysis for each actual response
+ * {@link \core_question\statistics\responses\analysis_for_actual_response}.
+ *
+ * @package    core_question
+ * @copyright  2014 The Open University
+ * @author     James Pratt me@jamiep.org
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 class analysis_for_actual_response {
     /**
-     * @var int count of this response
+     * @var int[] count per try for this response.
      */
-    protected $count;
+    protected $trycount = array();
+
+    /**
+     * @var int total count of tries with this response.
+     */
+    protected $totalcount = 0;
 
     /**
      * @var float grade for this response, normally between 0 and 1.
@@ -43,32 +66,54 @@ class analysis_for_actual_response {
     /**
      * @param string $response
      * @param float  $fraction
-     * @param int    $count     defaults to zero, this param used when loading from db.
      */
-    public function __construct($response, $fraction, $count = 0) {
+    public function __construct($response, $fraction) {
         $this->response = $response;
         $this->fraction = $fraction;
-        $this->count = $count;
     }
 
     /**
      * Used to count the occurrences of response sub parts.
+     *
+     * @param int $try the try number, or 0 if only keeping one count, not a count for each try.
      */
-    public function increment_count() {
-        $this->count++;
+    public function increment_count($try = 0) {
+        $this->totalcount++;
+        if ($try != 0) {
+            if (!isset($this->trycount[$try])) {
+                $this->trycount[$try] = 0;
+            }
+            $this->trycount[$try]++;
+        }
+
     }
 
     /**
-     * @param \qubaid_condition $qubaids
-     * @param int               $questionid the question id
-     * @param int               $variantno
-     * @param string            $subpartid
-     * @param string            $responseclassid
+     * Used to set the count of occurrences of response sub parts, when loading count from cache.
+     *
+     * @param int $try the try number, or 0 if only keeping one count, not a count for each try.
+     * @param int $count
      */
-    public function cache($qubaids, $questionid, $variantno, $subpartid, $responseclassid) {
+    public function set_count($try, $count) {
+        $this->totalcount = $this->totalcount + $count;
+        $this->trycount[$try] = $count;
+    }
+
+    /**
+     * Cache analysis for class.
+     *
+     * @param \qubaid_condition $qubaids    which question usages have been analysed.
+     * @param string            $whichtries which tries have been analysed?
+     * @param int               $questionid which question.
+     * @param int               $variantno  which variant.
+     * @param string            $subpartid which sub part is this actual response in?
+     * @param string            $responseclassid which response class is this actual response in?
+     */
+    public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $responseclassid) {
         global $DB;
         $row = new \stdClass();
         $row->hashcode = $qubaids->get_hash_code();
+        $row->whichtries = $whichtries;
         $row->questionid = $questionid;
         $row->variant = $variantno;
         $row->subqid = $subpartid;
@@ -78,17 +123,29 @@ class analysis_for_actual_response {
             $row->aid = $responseclassid;
         }
         $row->response = $this->response;
-        $row->rcount = $this->count;
         $row->credit = $this->fraction;
         $row->timemodified = time();
-        $DB->insert_record('question_response_analysis', $row, false);
-    }
-
-    public function response_matches($response) {
-        return $response == $this->response;
+        $analysisid = $DB->insert_record('question_response_analysis', $row);
+        if ($whichtries === \question_attempt::ALL_TRIES) {
+            foreach ($this->trycount as $try => $count) {
+                $countrow = new \stdClass();
+                $countrow->try = $try;
+                $countrow->rcount = $count;
+                $countrow->analysisid = $analysisid;
+                $DB->insert_record('question_response_count', $countrow, false);
+            }
+        } else {
+            $countrow = new \stdClass();
+            $countrow->try = 0;
+            $countrow->rcount = $this->totalcount;
+            $countrow->analysisid = $analysisid;
+            $DB->insert_record('question_response_count', $countrow, false);
+        }
     }
 
     /**
+     * Returns an object with a property for each column of the question response analysis table.
+     *
      * @param string $partid
      * @param string $modelresponse
      * @return object
@@ -99,7 +156,17 @@ class analysis_for_actual_response {
         $rowdata->responseclass = $modelresponse;
         $rowdata->response = $this->response;
         $rowdata->fraction = $this->fraction;
-        $rowdata->count = $this->count;
+        $rowdata->totalcount = $this->totalcount;
+        $rowdata->trycount = $this->trycount;
         return $rowdata;
     }
+
+    /**
+     * What is the highest try number that this response has been seen?
+     *
+     * @return int try number
+     */
+    public function get_maximum_tries() {
+        return max(array_keys($this->trycount));
+    }
 }
index 8f43ee7..42c4aff 100644 (file)
@@ -26,39 +26,48 @@ namespace core_question\statistics\responses;
 
 
 /**
- * Represents an actual part of the response that has been classified in a class of responses for this sub part of the question.
- *
- * A question and it's response is represented as having one or more sub parts where the response to each sub-part might fall
- * into one of one or more classes.
+ * Counts a class of responses for this sub part of the question.
  *
  * No response is one possible class of response to a question.
  *
- * @copyright  2010 The Open University
+ * - There is a separate data structure for each question or sub question's analysis
+ * {@link \core_question\statistics\responses\analysis_for_question}
+ * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}.
+ * - There are separate analysis for each variant in this top level instance.
+ * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question.
+ * {@link \core_question\statistics\responses\analysis_for_subpart}.
+ * - Then within the sub part analysis there are response class analysis
+ * {@link \core_question\statistics\responses\analysis_for_class}.
+ * - Then within each class analysis there are analysis for each actual response
+ * {@link \core_question\statistics\responses\analysis_for_actual_response}.
+ *
+ * @package    core_question
+ * @copyright  2014 The Open University
+ * @author     James Pratt me@jamiep.org
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class analysis_for_class {
 
     /**
-     * @var string
+     * @var string must be unique for each response class within this sub part.
      */
     protected $responseclassid;
 
     /**
-     * @var string
+     * @var string represent this class in the response analysis table.
      */
     protected $modelresponse;
 
     /** @var string the (partial) credit awarded for this responses. */
     protected $fraction;
 
-    /**
-     *
-     * @var analysis_for_actual_response[] key is the actual response represented as a string as it will be displayed in report.
+    /** @var analysis_for_actual_response[] key is the actual response represented as a string as it will be displayed in report.
      */
     protected $actualresponses = array();
 
     /**
      * Constructor, just an easy way to set the fields.
+     *
      * @param \question_possible_response $possibleresponse
      * @param string                      $responseclassid
      */
@@ -69,56 +78,88 @@ class analysis_for_class {
     }
 
     /**
-     * @param string $actualresponse
+     * Keep a count of a response to this question sub part that falls within this class.
+     *
+     * @param string     $actualresponse
      * @param float|null $fraction
+     * @param int        $try
+     * @return \core_question\statistics\responses\analysis_for_actual_response
      */
-    public function count_response($actualresponse, $fraction) {
+    public function count_response($actualresponse, $fraction, $try) {
         if (!isset($this->actualresponses[$actualresponse])) {
             if ($fraction === null) {
                 $fraction = $this->fraction;
             }
-            $this->actualresponses[$actualresponse] = new analysis_for_actual_response($actualresponse, $fraction);
+            $this->add_response($actualresponse, $fraction);
         }
-        $this->actualresponses[$actualresponse]->increment_count();
+        $this->get_response($actualresponse)->increment_count($try);
     }
 
     /**
-     * @param \qubaid_condition $qubaids
-     * @param int               $questionid the question id
-     * @param int               $variantno
-     * @param string            $subpartid
+     * Cache analysis for class.
+     *
+     * @param \qubaid_condition $qubaids    which question usages have been analysed.
+     * @param string            $whichtries which tries have been analysed?
+     * @param int               $questionid which question.
+     * @param int               $variantno  which variant.
+     * @param string            $subpartid  which sub part.
      */
-    public function cache($qubaids, $questionid, $variantno, $subpartid) {
-        foreach ($this->actualresponses as $response => $actualresponse) {
-            $actualresponse->cache($qubaids, $questionid, $variantno, $subpartid, $this->responseclassid, $response);
+    public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid) {
+        foreach ($this->get_responses() as $response) {
+            $analysisforactualresponse = $this->get_response($response);
+            $analysisforactualresponse->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $this->responseclassid);
         }
     }
 
-    public function add_response_and_count($response, $fraction, $count) {
-        $this->actualresponses[$response] = new analysis_for_actual_response($response, $fraction, $count);
+    /**
+     * Add an actual response to the data structure.
+     *
+     * @param string $response A string representing the actual response.
+     * @param float  $fraction The fraction of grade awarded for this response.
+     */
+    public function add_response($response, $fraction) {
+        $this->actualresponses[$response] = new analysis_for_actual_response($response, $fraction);
     }
 
     /**
+     * Used when loading cached counts.
+     *
+     * @param string $response
+     * @param int $try the try number, will be zero if not keeping track of try.
+     * @param int $count the count
+     */
+    public function set_response_count($response, $try, $count) {
+        $this->actualresponses[$response]->set_count($try, $count);
+    }
+
+    /**
+     * Are there actual responses to sub parts that where classified into this class?
+     *
      * @return bool whether this analysis has a response class with more than one
      *      different actual response, or if the actual response is different from
      *      the model response.
      */
     public function has_actual_responses() {
-        if (count($this->actualresponses) > 1) {
+        $actualresponses = $this->get_responses();
+        if (count($actualresponses) > 1) {
             return true;
-        } else if (count($this->actualresponses) == 1) {
-            $onlyactualresponse = reset($this->actualresponses);
-            return !$onlyactualresponse->response_matches($this->modelresponse);
+        } else if (count($actualresponses) === 1) {
+            $singleactualresponse = reset($actualresponses);
+            return $singleactualresponse != $this->modelresponse;
         }
         return false;
     }
 
     /**
+     * Return the data to display in the response analysis table.
+     *
+     * @param bool $responseclasscolumn
+     * @param string $partid
      * @return object[]
      */
     public function data_for_question_response_table($responseclasscolumn, $partid) {
         $return = array();
-        if (empty($this->actualresponses)) {
+        if (count($this->get_responses()) == 0) {
             $rowdata = new \stdClass();
             $rowdata->part = $partid;
             $rowdata->responseclass = $this->modelresponse;
@@ -128,13 +169,47 @@ class analysis_for_class {
                 $rowdata->response = '';
             }
             $rowdata->fraction = $this->fraction;
-            $rowdata->count = 0;
+            $rowdata->totalcount = 0;
+            $rowdata->trycount = array();
             $return[] = $rowdata;
         } else {
-            foreach ($this->actualresponses as $actualresponse) {
-                $return[] = $actualresponse->data_for_question_response_table($partid, $this->modelresponse);
+            foreach ($this->get_responses() as $actualresponse) {
+                $response = $this->get_response($actualresponse);
+                $return[] = $response->data_for_question_response_table($partid, $this->modelresponse);
             }
         }
         return $return;
     }
+
+    /**
+     * What is the highest try number that an actual response of this response class has been seen?
+     *
+     * @return int try number
+     */
+    public function get_maximum_tries() {
+        $max = 1;
+        foreach ($this->get_responses() as $actualresponse) {
+            $max = max($max, $this->get_response($actualresponse)->get_maximum_tries());
+        }
+        return $max;
+    }
+
+    /**
+     * Return array of the actual responses to this sub part that were classified into this class.
+     *
+     * @return string[] the actual responses we are counting tries at.
+     */
+    protected function get_responses() {
+        return array_keys($this->actualresponses);
+    }
+
+    /**
+     * Get the data structure used to count the responses that match an actual response within this class of responses.
+     *
+     * @param string $response
+     * @return analysis_for_actual_response the instance for keeping count of tries for $response.
+     */
+    protected function get_response($response) {
+        return $this->actualresponses[$response];
+    }
 }
index 0fd1de6..5be3e42 100644 (file)
@@ -29,17 +29,29 @@ defined('MOODLE_INTERNAL') || die();
 
 /**
  * Analysis for possible responses for parts of a question. It is up to a question type designer to decide on how many parts their
- * question has. A sub part might represent a sub question embedded in the question for example in a matching question there are
+ * question has. See {@link \question_type::get_possible_responses()} and sub classes where the sub parts and response classes are
+ * defined.
+ *
+ * A sub part might represent a sub question embedded in the question for example in a matching question there are
  * several sub parts. A numeric question with a unit might be divided into two sub parts for the purposes of response analysis
  * or the question type designer might decide to treat the answer, both the numeric and unit part,
  * as a whole for the purposes of response analysis.
  *
- * Responses can be further divided into 'classes' in which they are classified. One or more of these 'classes' are contained in
- * the responses
+ * - There is a separate data structure for each question or sub question's analysis
+ * {@link \core_question\statistics\responses\analysis_for_question}
+ * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}.
+ * - There are separate analysis for each variant in this top level instance.
+ * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question.
+ * {@link \core_question\statistics\responses\analysis_for_subpart}.
+ * - Then within the sub part analysis there are response class analysis
+ * {@link \core_question\statistics\responses\analysis_for_class}.
+ * - Then within each class analysis there are analysis for each actual response
+ * {@link \core_question\statistics\responses\analysis_for_actual_response}.
  *
- * @copyright 2013 Open University
- * @author    Jamie Pratt <me@jamiep.org>
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @package    core_question
+ * @copyright  2014 The Open University
+ * @author     James Pratt me@jamiep.org
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class analysis_for_question {
 
@@ -149,6 +161,23 @@ class analysis_for_question {
         return false;
     }
 
+    /**
+     * @return bool Does this response analysis include counts for responses for multiple tries of the question?
+     */
+    public function has_multiple_tries_data() {
+        return false;
+    }
+
+    /**
+     * What is the highest number of tries at this question?
+     *
+     * @return int always 1 as this class is for analysing only one try.
+     */
+    public function get_maximum_tries() {
+        return 1;
+    }
+
+
     /**
      * Takes an array of {@link \question_classified_response} and adds counts of the responses to the sub parts and classes.
      *
@@ -162,13 +191,15 @@ class analysis_for_question {
     }
 
     /**
-     * @param \qubaid_condition $qubaids
-     * @param int               $questionid the question id
+     * @param \qubaid_condition $qubaids    which question usages have been analysed.
+     * @param string            $whichtries which tries have been analysed?
+     * @param int               $questionid which question.
      */
-    public function cache($qubaids, $questionid) {
+    public function cache($qubaids, $whichtries, $questionid) {
         foreach ($this->get_variant_nos() as $variantno) {
             foreach ($this->get_subpart_ids($variantno) as $subpartid) {
-                $this->get_analysis_for_subpart($variantno, $subpartid)->cache($qubaids, $questionid, $variantno, $subpartid);
+                $analysisforsubpart = $this->get_analysis_for_subpart($variantno, $subpartid);
+                $analysisforsubpart->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid);
             }
         }
     }
diff --git a/question/classes/statistics/responses/analysis_for_question_all_tries.php b/question/classes/statistics/responses/analysis_for_question_all_tries.php
new file mode 100644 (file)
index 0000000..86deddf
--- /dev/null
@@ -0,0 +1,84 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file contains a class to analyse all the responses for multiple tries at a particular question.
+ *
+ * @package    core_question
+ * @copyright  2014 Open University
+ * @author     Jamie Pratt <me@jamiep.org>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_question\statistics\responses;
+
+/**
+ * Analysis for possible responses for parts of a question with multiple submitted responses.
+ *
+ * If the analysis was for a single try it would be handled by {@link \core_question\statistics\responses\analysis_for_question}.
+ *
+ * - There is a separate data structure for each question or sub question's analysis
+ * {@link \core_question\statistics\responses\analysis_for_question}
+ * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}.
+ * - There are separate analysis for each variant in this top level instance.
+ * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question.
+ * {@link \core_question\statistics\responses\analysis_for_subpart}.
+ * - Then within the sub part analysis there are response class analysis
+ * {@link \core_question\statistics\responses\analysis_for_class}.
+ * - Then within each class analysis there are analysis for each actual response
+ * {@link \core_question\statistics\responses\analysis_for_actual_response}.
+ *
+ * @package    core_question
+ * @copyright  2014 The Open University
+ * @author     James Pratt me@jamiep.org
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class analysis_for_question_all_tries extends analysis_for_question{
+    /**
+     * Constructor.
+     *
+     * @param int      $variantno               variant number
+     * @param \array[] $responsepartsforeachtry for question with multiple tries we expect an array with first index being try no
+     *                                          then second index is subpartid and values are \question_classified_response
+     */
+    public function count_response_parts($variantno, $responsepartsforeachtry) {
+        foreach ($responsepartsforeachtry as $try => $responseparts) {
+            foreach ($responseparts as $subpartid => $responsepart) {
+                $this->get_analysis_for_subpart($variantno, $subpartid)->count_response($responsepart, $try);
+            }
+        }
+    }
+
+    public function has_multiple_tries_data() {
+        return true;
+    }
+
+    /**
+     * What is the highest number of tries at this question?
+     *
+     * @return int try number
+     */
+    public function get_maximum_tries() {
+        $max = 1;
+        foreach ($this->get_variant_nos() as $variantno) {
+            foreach ($this->get_subpart_ids($variantno) as $subpartid) {
+                $max = max($max, $this->get_analysis_for_subpart($variantno, $subpartid)->get_maximum_tries());
+            }
+        }
+        return $max;
+    }
+
+}
index 53d9913..a9e8a86 100644 (file)
 
 /**
  *
- * 'Classes' to classify the sub parts of a question response into.
+ * Data structure to count responses for each of the sub parts of a question.
  *
  * @package    core_question
- * @copyright  2013 The Open University
+ * @copyright  2014 The Open University
  * @author     James Pratt me@jamiep.org
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
 namespace core_question\statistics\responses;
 
-
+/**
+ * Representing the analysis of each of the sub parts of each variant of the question.
+ *
+ * - There is a separate data structure for each question or sub question's analysis
+ * {@link \core_question\statistics\responses\analysis_for_question}
+ * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}.
+ * - There are separate analysis for each variant in this top level instance.
+ * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question.
+ * {@link \core_question\statistics\responses\analysis_for_subpart}.
+ * - Then within the sub part analysis there are response class analysis
+ * {@link \core_question\statistics\responses\analysis_for_class}.
+ * - Then within each class analysis there are analysis for each actual response
+ * {@link \core_question\statistics\responses\analysis_for_actual_response}.
+ *
+ * @package    core_question
+ * @copyright  2014 The Open University
+ * @author     James Pratt me@jamiep.org
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 class analysis_for_subpart {
 
     /**
-     * Takes an array of possible_responses - ({@link \question_possible_response} objects).
-     * Or takes an array of {@link \question_possible_response} objects.
+     * Takes an array of possible_responses as returned from {@link \question_type::get_possible_responses()}.
      *
-     * @param \question_possible_response[] $responseclasses
+     * @param \question_possible_response[] $responseclasses as returned from {@link \question_type::get_possible_responses()}.
      */
     public function __construct(array $responseclasses = null) {
         if (is_array($responseclasses)) {
@@ -44,7 +61,6 @@ class analysis_for_subpart {
     }
 
     /**
-     *
      * @var analysis_for_class[]
      */
     protected $responseclasses;
@@ -59,6 +75,8 @@ class analysis_for_subpart {
     }
 
     /**
+     * Get the instance of the class handling the analysis of $classid for this sub part.
+     *
      * @param string $classid id for response class.
      * @return analysis_for_class
      */
@@ -66,40 +84,68 @@ class analysis_for_subpart {
         return $this->responseclasses[$classid];
     }
 
+    /**
+     * Whether there is more than one response class for responses in this question sub part?
+     *
+     * @return bool Are there?
+     */
     public function has_multiple_response_classes() {
-        return count($this->responseclasses) > 1;
+        return count($this->get_response_class_ids()) > 1;
     }
 
     /**
+     * Count a part of a response.
+     *
      * @param \question_classified_response $subpart
+     * @param int $try the try number or zero if not keeping track of try number
      */
-    public function count_response($subpart) {
-        $this->responseclasses[$subpart->responseclassid]->count_response($subpart->response, $subpart->fraction);
+    public function count_response($subpart, $try = 0) {
+        $responseanalysisforclass = $this->get_response_class($subpart->responseclassid);
+        $responseanalysisforclass->count_response($subpart->response, $subpart->fraction, $try);
     }
 
     /**
-     * @param \qubaid_condition $qubaids
-     * @param int               $questionid the question id
-     * @param int               $variantno
-     * @param string            $subpartid
+     * Cache analysis for sub part.
+     *
+     * @param \qubaid_condition $qubaids    which question usages have been analysed.
+     * @param string            $whichtries which tries have been analysed?
+     * @param int               $questionid which question.
+     * @param int               $variantno  which variant.
+     * @param string            $subpartid  which sub part.
      */
-    public function cache($qubaids, $questionid, $variantno, $subpartid) {
-        foreach ($this->responseclasses as $responseclassid => $responseclass) {
-            $responseclass->cache($qubaids, $questionid, $variantno, $subpartid, $responseclassid);
+    public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid) {
+        foreach ($this->get_response_class_ids() as $responseclassid) {
+            $analysisforclass = $this->get_response_class($responseclassid);
+            $analysisforclass->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $responseclassid);
         }
     }
 
     /**
+     * Has actual responses different to the model response for this class?
+     *
      * @return bool whether this analysis has a response class with more than one
      *      different actual response, or if the actual response is different from
      *      the model response.
      */
     public function has_actual_responses() {
-        foreach ($this->responseclasses as $responseclass) {
-            if ($responseclass->has_actual_responses()) {
+        foreach ($this->get_response_class_ids() as $responseclassid) {
+            if ($this->get_response_class($responseclassid)->has_actual_responses()) {
                 return true;
             }
         }
         return false;
     }
+
+    /**
+     * What is the highest try number for this sub part?
+     *
+     * @return int max tries
+     */
+    public function get_maximum_tries() {
+        $max = 1;
+        foreach ($this->get_response_class_ids() as $responseclassid) {
+            $max = max($max, $this->get_response_class($responseclassid)->get_maximum_tries());
+        }
+        return $max;
+    }
 }
index c000b20..7b0685c 100644 (file)
@@ -691,10 +691,11 @@ ORDER BY qa.slot
     /**
      * Load a {@link question_attempt} from the database, including all its
      * steps.
+     *
      * @param int $questionid the question to load all the attempts fors.
      * @param qubaid_condition $qubaids used to restrict which usages are included
      * in the query. See {@link qubaid_condition}.
-     * @return array of question_attempts.
+     * @return question_attempt[] array of question_attempts that were loaded.
      */
     public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
         $sql = "
index 7b33bf6..bce0465 100644 (file)
@@ -1321,14 +1321,19 @@ class question_attempt {
     }
 
     /**
-     * Break down a student response by sub part and classification.
-     * See also {@link question_type::get_possible_responses()}
+     * Break down a student response by sub part and classification. See also {@link question::classify_response}.
      * Used for response analysis.
      *
-     * @return question_possible_response[] where keys are subpartid.
+     * @param string $whichtries         which tries to analyse for response analysis. Will be one of
+     *                                   question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
+     *                                   Defaults to question_attempt::LAST_TRY.
+     * @return (question_classified_response|array)[] If $whichtries is question_attempt::FIRST_TRY or LAST_TRY index is subpartid
+     *                                   and values are question_classified_response instances.
+     *                                   If $whichtries is question_attempt::ALL_TRIES then first key is submitted response no
+     *                                   and the second key is subpartid.
      */
-    public function classify_response() {
-        return $this->behaviour->classify_response();
+    public function classify_response($whichtries = self::LAST_TRY) {
+        return $this->behaviour->classify_response($whichtries);
     }
 
     /**
@@ -1686,7 +1691,7 @@ class question_attempt_steps_with_submitted_response_iterator extends question_a
     }
     /** @return bool */
     public function valid() {
-        return $this->offsetExists($this->submittedresponseno);
+        return $this->submittedresponseno >= 1 && $this->submittedresponseno <= count($this->stepswithsubmittedresponses);
     }
 
     /**
index 363c102..9538346 100644 (file)
@@ -32,12 +32,19 @@ defined('MOODLE_INTERNAL') || die();
 function question_usage_statistics_cron() {
     global $DB;
 
-    $expiretime = time() - 5*HOURSECS;
+    $expiretime = time() - 5 * HOURSECS;
 
     mtrace("\n  Cleaning up old question statistics cache records...", '');
 
     $DB->delete_records_select('question_statistics', 'timemodified < ?', array($expiretime));
-    $DB->delete_records_select('question_response_analysis', 'timemodified < ?', array($expiretime));
+    $responseanlysisids = $DB->get_records_select_menu('question_response_analysis',
+                                                           'timemodified < ?',
+                                                           array($expiretime),
+                                                           'id',
+                                                           'id, id AS id2');
+
+    $DB->delete_records_list('question_response_analysis', 'id', $responseanlysisids);
+    $DB->delete_records_list('question_response_count', 'analysisid', $responseanlysisids);
 
     mtrace('done.');
     return true;
index 9dd5763..c2f2e6e 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2014031400.01;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2014031400.04;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.