From 5c0654d7ff7cfd409cf7aa3b62d3f3db415ee93d Mon Sep 17 00:00:00 2001 From: Simey Lameze Date: Thu, 27 Sep 2018 10:16:02 +0800 Subject: [PATCH] MDL-63185 mod_quiz: change APIs to handle attempts on behat Thanks to Tim Hunt for all the help. Part of MDL-62610 --- mod/quiz/attemptlib.php | 20 ++++++--- mod/quiz/locallib.php | 16 ++++--- mod/quiz/tests/generator/lib.php | 56 ++++++++++++++++++++++++ question/tests/generator/lib.php | 60 ++++++++++++++++++++++++++ question/type/missingtype/question.php | 4 ++ question/type/questionbase.php | 17 +++++++- question/type/truefalse/question.php | 10 +++++ 7 files changed, 171 insertions(+), 12 deletions(-) diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index c65a65930da..510f30b9173 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -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; } diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index e27fbc8aeab..0c46d2728a4 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -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); } diff --git a/mod/quiz/tests/generator/lib.php b/mod/quiz/tests/generator/lib.php index db1703f1331..ef9100fb3eb 100644 --- a/mod/quiz/tests/generator/lib.php +++ b/mod/quiz/tests/generator/lib.php @@ -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); + } + } } diff --git a/question/tests/generator/lib.php b/question/tests/generator/lib.php index bdbcca596fc..66ca045c83b 100644 --- a/question/tests/generator/lib.php +++ b/question/tests/generator/lib.php @@ -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; + } } diff --git a/question/type/missingtype/question.php b/question/type/missingtype/question.php index fa22cddbfc1..c30c0b2b492 100644 --- a/question/type/missingtype/question.php +++ b/question/type/missingtype/question.php @@ -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(); } diff --git a/question/type/questionbase.php b/question/type/questionbase.php index 32dbcf8fd2e..6dfc5dba595 100644 --- a/question/type/questionbase.php +++ b/question/type/questionbase.php @@ -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.'); + } } diff --git a/question/type/truefalse/question.php b/question/type/truefalse/question.php index d3796a6a821..05d1d694169 100644 --- a/question/type/truefalse/question.php +++ b/question/type/truefalse/question.php @@ -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());