MDL-63185 mod_quiz: change APIs to handle attempts on behat
authorSimey Lameze <simey@moodle.com>
Thu, 27 Sep 2018 02:16:02 +0000 (10:16 +0800)
committerSimey Lameze <simey@moodle.com>
Fri, 28 Sep 2018 03:07:21 +0000 (11:07 +0800)
Thanks to Tim Hunt for all the help.

Part of MDL-62610

mod/quiz/attemptlib.php
mod/quiz/locallib.php
mod/quiz/tests/generator/lib.php
question/tests/generator/lib.php
question/type/missingtype/question.php
question/type/questionbase.php
question/type/truefalse/question.php

index c65a659..510f30b 100644 (file)
@@ -1101,7 +1101,7 @@ class quiz_attempt {
      * @return question_usage_by_activity the usage.
      */
     public function get_question_usage() {
-        if (!PHPUNIT_TEST) {
+        if (!(PHPUNIT_TEST || defined('BEHAT_TEST'))) {
             throw new coding_exception('get_question_usage is only for use in unit tests. ' .
                     'For other operations, use the quiz_attempt api, or extend it properly.');
         }
@@ -1814,10 +1814,12 @@ class quiz_attempt {
      * @param int  $timestamp  the timestamp that should be stored as the modifed
      *                         time in the database for these actions. If null, will use the current time.
      * @param bool $becomingoverdue
-     * @param array|null $simulatedresponses If not null, then we are testing, and this is an array of simulated data, keys are slot
-     *                                          nos and values are arrays representing student responses which will be passed to
-     *                                          question_definition::prepare_simulated_post_data method and then have the
-     *                                          appropriate prefix added.
+     * @param array|null $simulatedresponses If not null, then we are testing, and this is an array of simulated data.
+     *      There are two formats supported here, for historical reasons. The newer approach is to pass an array created by
+     *      {@link core_question_generator::get_simulated_post_data_for_questions_in_usage()}.
+     *      the second is to pass an array slot no => contains arrays representing student
+     *      responses which will be passed to {@link question_definition::prepare_simulated_post_data()}.
+     *      This second method will probably get deprecated one day.
      */
     public function process_submitted_actions($timestamp, $becomingoverdue = false, $simulatedresponses = null) {
         global $DB;
@@ -1825,7 +1827,13 @@ class quiz_attempt {
         $transaction = $DB->start_delegated_transaction();
 
         if ($simulatedresponses !== null) {
-            $simulatedpostdata = $this->quba->prepare_simulated_post_data($simulatedresponses);
+            if (is_int(key($simulatedresponses))) {
+                // Legacy approach. Should be removed one day.
+                $simulatedpostdata = $this->quba->prepare_simulated_post_data($simulatedresponses);
+            } else {
+                $simulatedpostdata = $simulatedresponses;
+            }
+
         } else {
             $simulatedpostdata = null;
         }
index e27fbc8..0c46d27 100644 (file)
@@ -2373,14 +2373,19 @@ function quiz_validate_new_attempt(quiz $quizobj, quiz_access_manager $accessman
 /**
  * Prepare and start a new attempt deleting the previous preview attempts.
  *
- * @param  quiz $quizobj quiz object
- * @param  int $attemptnumber the attempt number
- * @param  object $lastattempt last attempt object
+ * @param quiz $quizobj quiz object
+ * @param int $attemptnumber the attempt number
+ * @param object $lastattempt last attempt object
  * @param bool $offlineattempt whether is an offline attempt or not
+ * @param array $forcedrandomquestions slot number => question id. Used for random questions,
+ *      to force the choice of a particular actual question. Intended for testing purposes only.
+ * @param array $forcedvariants slot number => variant. Used for questions with variants,
+ *      to force the choice of a particular variant. Intended for testing purposes only.
  * @return object the new attempt
  * @since  Moodle 3.1
  */
-function quiz_prepare_and_start_new_attempt(quiz $quizobj, $attemptnumber, $lastattempt, $offlineattempt = false) {
+function quiz_prepare_and_start_new_attempt(quiz $quizobj, $attemptnumber, $lastattempt,
+        $offlineattempt = false, $forcedrandomquestions = [], $forcedvariants = []) {
     global $DB, $USER;
 
     // Delete any previous preview attempts belonging to this user.
@@ -2394,7 +2399,8 @@ function quiz_prepare_and_start_new_attempt(quiz $quizobj, $attemptnumber, $last
     $attempt = quiz_create_attempt($quizobj, $attemptnumber, $lastattempt, $timenow, $quizobj->is_preview_user());
 
     if (!($quizobj->get_quiz()->attemptonlast && $lastattempt)) {
-        $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow);
+        $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow,
+                $forcedrandomquestions, $forcedvariants);
     } else {
         $attempt = quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt);
     }
index db1703f..ef9100f 100644 (file)
@@ -95,4 +95,60 @@ class mod_quiz_generator extends testing_module_generator {
 
         return parent::create_instance($record, (array)$options);
     }
+
+    /**
+     * Create a quiz attempt for a particular user at a particular course.
+     *
+     * Currently this method can only create a first attempt for each
+     * user at each quiz. TODO remove this limitation.
+     *
+     * @param int $quizid the quiz id (from the mdl_quit table, not cmid).
+     * @param int $userid the user id.
+     * @param array $forcedrandomquestions slot => questionid. Optional,
+     *      used with random questions, to control which one is 'randomly' selected in that slot.
+     * @param array $forcedvariants slot => variantno. Optional. Optional,
+     *      used with question where get_num_variants is > 1, to control which
+     *      variants is 'randomly' selected.
+     * @return stdClass the new attempt.
+     */
+    public function create_attempt($quizid, $userid, array $forcedrandomquestions = [],
+            array $forcedvariants = []) {
+        // Build quiz object and load questions.
+        $quizobj = quiz::create($quizid, $userid);
+
+        if (quiz_get_user_attempts($quizid, $userid, 'all', true)) {
+            throw new coding_exception('mod_quiz_generator is currently limited to only ' .
+                    'be able to create one attempt for each user. (This should be fixed.)');
+        }
+
+        return quiz_prepare_and_start_new_attempt($quizobj, 1, null,
+                $offlineattempt = false, $forcedrandomquestions = [], $forcedvariants);
+    }
+
+    /**
+     * Submit responses to a quiz attempt.
+     *
+     * To be realistic, you should ensure that $USER is set to the user whose attempt
+     * it is before calling this.
+     *
+     * @param int $attemptid the id of the attempt which is being
+     * @param array $responses array responses to submit. See description on
+     *      {@link core_question_generator::get_simulated_post_data_for_questions_in_usage()}.
+     * @param bool $finishattempt of true, the attempt will be submitted.
+     */
+    public function submit_responses($attemptid, array $responses, $finishattempt) {
+        /** @var $questiongenerator core_question_generator */
+        $questiongenerator = $this->datagenerator->get_plugin_generator('core_question');
+
+        $attemptobj = quiz_attempt::create($attemptid);
+
+        $postdata = $questiongenerator->get_simulated_post_data_for_questions_in_usage(
+                $attemptobj->get_question_usage(), $responses);
+
+        $attemptobj->process_submitted_actions(time(), false, $postdata);
+
+        if ($finishattempt) {
+            $attemptobj->process_finish(time(), false);
+        }
+    }
 }
index bdbcca5..66ca045 100644 (file)
@@ -154,4 +154,64 @@ class core_question_generator extends component_generator_base {
 
         return array($category, $course, $qcat, $questions);
     }
+
+    /**
+     * This method can construct what the post data would be to simulate a user submitting
+     * responses to a number of questions within a question usage.
+     *
+     * In the responses array, the array keys are the slot numbers for which a response will
+     * be submitted. You can submit a response to any number of responses within the usage.
+     * There is no need to do them all. The values are a string representation of the response.
+     * The exact meaning of that depends on the particular question type. These strings
+     * are passed to the un_summarise_response method of the question to decode.
+     *
+     * @param question_usage_by_activity $quba the question usage.
+     * @param array $responses the resonses to submit, in the format described above.
+     * @return array that can be passed to methods like $quba->process_all_actions as simulated POST data.
+     */
+    public function get_simulated_post_data_for_questions_in_usage(
+            question_usage_by_activity $quba, array $responses) {
+        $postdata = [];
+
+        foreach ($responses as $slot => $responsesummary) {
+            $postdata += $this->get_simulated_post_data_for_question_attempt(
+                    $quba->get_question_attempt($slot), $responsesummary);
+        }
+
+        return $postdata;
+    }
+
+    /**
+     * This method can construct what the post data would be to simulate a user submitting
+     * responses to one particular question attempt.
+     *
+     * The $responsesummary is a string representation of the response to be submitted.
+     * The exact meaning of that depends on the particular question type. These strings
+     * are passed to the un_summarise_response method of the question to decode.
+     *
+     * @param question_attempt $qa the question attempt for which we are generating POST data.
+     * @param $responsesummary a textual summary of the resonse, as described above.
+     * @return array the sumulated post data that can be passed to $quba->process_all_actions.
+     */
+    public function get_simulated_post_data_for_question_attempt(
+            question_attempt $qa, $responsesummary) {
+
+        $question = $qa->get_question();
+        if (!$question instanceof question_with_responses) {
+            return [];
+        }
+
+        $postdata = [];
+        $postdata[$qa->get_control_field_name('sequencecheck')] = (string)$qa->get_sequence_check_count();
+        $postdata[$qa->get_flag_field_name()] = (string)(int)$qa->is_flagged();
+
+        $response = $question->un_summarise_response($responsesummary);
+        foreach ($response as $name => $value) {
+            $postdata[$qa->get_qt_field_name($name)] = (string)$value;
+        }
+
+        // TODO handle behaviour variables.
+
+        return $postdata;
+    }
 }
index fa22cdd..c30c0b2 100644 (file)
@@ -73,6 +73,10 @@ class qtype_missingtype_question extends question_definition
         return null;
     }
 
+    public function un_summarise_response(string $response) {
+        return [];
+    }
+
     public function classify_response(array $response) {
         return array();
     }
index 32dbcf8..6dfc5db 100644 (file)
@@ -505,11 +505,21 @@ interface question_manually_gradable {
 
     /**
      * Produce a plain text summary of a response.
-     * @param $response a response, as might be passed to {@link grade_response()}.
+     * @param array $response a response, as might be passed to {@link grade_response()}.
      * @return string a plain text summary of that response, that could be used in reports.
      */
     public function summarise_response(array $response);
 
+    /**
+     * If possible, construct a response that could have lead to the given
+     * response summary. This is basically the opposite of {@link summarise_response()}
+     * but it is intended only to be used for testing.
+     *
+     * @param string $summary a string, which might have come from summarise_response
+     * @return array a response that could have lead to that.
+     */
+    public function un_summarise_response(string $summary);
+
     /**
      * Categorise the student's response according to the categories defined by
      * get_possible_responses.
@@ -641,6 +651,11 @@ abstract class question_with_responses extends question_definition
     public function is_gradable_response(array $response) {
         return $this->is_complete_response($response);
     }
+
+    public function un_summarise_response(string $summary) {
+        throw new coding_exception('This question type (' . get_class($this) .
+                ' does not implement the un_summarise_response testing method.');
+    }
 }
 
 
index d3796a6..05d1d69 100644 (file)
@@ -59,6 +59,16 @@ class qtype_truefalse_question extends question_graded_automatically {
         }
     }
 
+    public function un_summarise_response(string $summary) {
+        if ($summary === get_string('true', 'qtype_truefalse')) {
+            return ['answer' => '1'];
+        } else if ($summary === get_string('false', 'qtype_truefalse')) {
+            return ['answer' => '0'];
+        } else {
+            return [];
+        }
+    }
+
     public function classify_response(array $response) {
         if (!array_key_exists('answer', $response)) {
             return array($this->id => question_classified_response::no_response());